Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
59bef59
feat(sites): add MAIN_SITE_FROM_DB category list config
albanm Sep 11, 2026
21ec878
feat(sites): resolve main site document presentation over env config
albanm Sep 11, 2026
87ef6c6
feat(sites): serve main site presentation from its document
albanm Sep 11, 2026
c7e3a16
fix(sites): make the injected theme hash describe the css actually se…
albanm Sep 11, 2026
f51c3c9
feat(mails): gate main-host mail theme and sender on the category list
albanm Sep 11, 2026
04aaf71
feat(sites): report ignored main-site fields via mainSiteWarnings
albanm Sep 11, 2026
c9584c0
fix(sites): do not re-toggle the account main site on the main document
albanm Sep 11, 2026
a0d3501
feat(ui): mark and explain the main site document in the admin pages
albanm Sep 11, 2026
124ad21
test(sites): verify the main-site admin page end to end
albanm Sep 12, 2026
05a038f
docs(sites): document main site config db vs env
albanm Sep 12, 2026
a032009
Merge remote-tracking branch 'origin/master' into chore-main-site-theme
albanm Sep 21, 2026
7a29427
fix(test): do not close the shared mongo client in the main-site unit…
albanm Sep 21, 2026
cdc9b66
refactor(sites): drop exports and context keys left over from dropped…
albanm Sep 21, 2026
9edf2d9
refactor(sites): render the main site through the ordinary site path
albanm Sep 21, 2026
4998afe
fix(test): reset the per-recipient mail budget between test runs
albanm Sep 21, 2026
acbb1f4
Merge remote-tracking branch 'origin/master' into chore-main-site-theme
albanm Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,5 @@ Read before changing anything in the corresponding area:
- [`docs/architecture/email-trust-and-site-isolation.md`](docs/architecture/email-trust-and-site-isolation.md) -- how SSO email claims are verified and how site-level SSO trust is confined so a compromised site config cannot escalate to superadmin or cross-site takeover. Required reading for changes to auth providers, `cleanUser`, `authProviderLoginCallback`, `adminMode`, or the change-host flow.
- [`docs/architecture/session-theft-protections.md`](docs/architecture/session-theft-protections.md) -- what protects a session whose cookies were copied: the split between the short lived `id_token` and the exchange token, server sessions and their recorded origin, the IP binding of superadmin sessions, and single use exchange tokens with reuse detection. Required reading for changes to `setSessionCookies`, `keepalive`, `logout`, or the `ServerSession` schema.
- [`docs/architecture/emails.md`](docs/architecture/emails.md) -- the outbound email pipeline: the two `/api/mails*` endpoints, sanitization/escape at the trust boundary, MJML template substitution, and `sendMailI18n`. Required reading for changes under `api/src/mails/`, the MJML templates, the mail schemas, or any caller that posts to `/api/mails`.
- [`docs/architecture/main-site-config.md`](docs/architecture/main-site-config.md) -- what the main site reads from its database document versus from environment variables, the `MAIN_SITE_FROM_DB` category list, and why the API reports rather than refuses. Required reading for changes to `api/src/sites/main-site.ts`, the `/api/sites/_*` presentation endpoints, `getSiteExtraParams` in `api/src/sites/spa-params.ts`, or the mail theming path.
- [`docs/architecture/non-human-identities.md`](docs/architecture/non-human-identities.md) -- how NHI (service account) JWT exchange is verified and confined to a single org with short-lived non-refreshable sessions. Required reading for changes to the nhi-token exchange, `api/src/nhis/`, or NHI-related guards.
1 change: 1 addition & 0 deletions api/config/custom-environment-variables.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ module.exports = {
depAdminIsOrgAdmin: 'DEP_ADMIN_IS_ORG_ADMIN',
manageSites: 'MANAGE_SITES',
acceptUnknownSite: 'ACCEPT_UNKNOWN_SITE',
mainSiteFromDb: jsonEnv('MAIN_SITE_FROM_DB'),
managePartners: 'MANAGE_PARTNERS',
manageNhis: 'MANAGE_NHIS',
nhisAllowInsecureIssuers: 'NHIS_ALLOW_INSECURE_ISSUERS',
Expand Down
8 changes: 8 additions & 0 deletions api/config/default.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,14 @@ module.exports = {
depAdminIsOrgAdmin: false,
manageSites: false,
acceptUnknownSite: false,
// Which categories of main-site configuration are read from its site document
// in the database instead of the environment. The "main site document" is a
// site whose host+path matches publicUrl. Empty means everything comes from
// env, which is the 8.x default; 9.0 will default to the full list.
// Never read from that document whatever this contains: authMode,
// authOnlyOtherSite, authProviders, applications, isAccountMain, owner,
// and user scoping. See docs/architecture/main-site-config.md
mainSiteFromDb: [],
managePartners: false,
manageNhis: false,
nhisAllowInsecureIssuers: false,
Expand Down
10 changes: 9 additions & 1 deletion api/config/type/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
"cleanup",
"avatars",
"noBirthday",
"passwordValidation"
"passwordValidation",
"mainSiteFromDb"
],
"properties": {
"info": {
Expand Down Expand Up @@ -212,6 +213,13 @@
"acceptUnknownSite": {
"type": "boolean"
},
"mainSiteFromDb": {
"type": "array",
"items": {
"type": "string",
"enum": ["theme", "title", "mails", "registration"]
}
},
"managePartners": {
"type": "boolean"
},
Expand Down
12 changes: 10 additions & 2 deletions api/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,8 @@ Can be 'anonymous', 'authenticated' or 'admin'.`,
sites: {
createSite: 'Define a new site',
loginOnSite: 'Sign in on the site',
colorWarnings: 'Contrast warnings'
colorWarnings: 'Contrast warnings',
mainSite: 'Main site',
},
passwordLists: {
help1: 'You can upload password lists from CSV files. Those too well known passwords will then be rejected if users try to use them.',
Expand All @@ -204,7 +205,8 @@ Can be 'anonymous', 'authenticated' or 'admin'.`,
confirmDelete: 'Delete this password list?'
},
site: {
title: 'Site configuration'
title: 'Site configuration',
mainSiteExplanation: 'This site matches this service\'s main domain. Its database document drives presentation only, and only for the categories listed in MAIN_SITE_FROM_DB. Authentication, identity providers and account scoping always come from environment variables.'
}
},
contact: {
Expand Down Expand Up @@ -559,5 +561,11 @@ Feel free to contact us at {contact}.
acceptedPartnerInvitation: 'The organization {partnerName} ({email}) has joined the organization {orgName} as a partner.',
addMemberTopic: 'a member has been added',
addMember: 'The user {name} ({email}) has joined the organization {orgName}.'
},
// server-side only (not in publicMessages): reported through
// mainSiteWarnings on the sites API and by the boot check
mainSite: {
ignoredField: 'The "{field}" field is ignored on the main site: this configuration comes from environment variables.',
ignoredCategory: 'The stored value for "{category}" is ignored: MAIN_SITE_FROM_DB does not contain this category.'
}
}
10 changes: 9 additions & 1 deletion api/i18n/fr.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ Peut valoir 'anonymous', 'authenticated' ou 'admin'.`,
createSite: 'Déclarer un nouveau site',
loginOnSite: 'Se connecter sur le site',
colorWarnings: 'Avertissements de contraste',
mainSite: 'Site principal',
},
passwordLists: {
help1: 'Vous pouvez charger des listes de mots de passe à partir de fichiers CSV. Ces mots de passe trop connus seront alors rejetés si des utilisateurs tentent de les utiliser.',
Expand All @@ -204,7 +205,8 @@ Peut valoir 'anonymous', 'authenticated' ou 'admin'.`,
confirmDelete: 'Supprimer cette liste de mots de passe ?'
},
site: {
title: 'Configuration du site'
title: 'Configuration du site',
mainSiteExplanation: 'Ce site correspond au domaine principal de ce service. Son document en base de données ne pilote que la présentation, et seulement pour les catégories listées dans MAIN_SITE_FROM_DB. L\'authentification, les fournisseurs d\'identité et le périmètre des comptes proviennent toujours des variables d\'environnement.'
}
},
contact: {
Expand Down Expand Up @@ -559,5 +561,11 @@ N'hésitez pas à nous contacter à {contact}.
acceptedPartnerInvitation: 'L\'organisation {partnerName} ({email}) a rejoint l\'organisation {orgName} en tant que partenaire.',
addMemberTopic: 'un membre a été ajouté',
addMember: 'L\'utilisateur {name} ({email}) a rejoint l\'organisation {orgName}.'
},
// server-side only (not in publicMessages): reported through
// mainSiteWarnings on the sites API and by the boot check
mainSite: {
ignoredField: 'Le champ "{field}" est ignoré sur le site principal : cette configuration provient des variables d\'environnement.',
ignoredCategory: 'La valeur enregistrée pour "{category}" est ignorée : MAIN_SITE_FROM_DB ne contient pas cette catégorie.'
}
}
24 changes: 2 additions & 22 deletions api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,7 @@ import tokens from './tokens/router.ts'
import sites from './sites/router.ts'
import accounts from './accounts/router.ts'
import passwordLists from './password-lists/router.ts'
import { getSiteByUrl } from '#services'
import { defaultThemeCssHash, getThemeCssHash } from './utils/theme.ts'
import { defaultPublicSiteInfoHash, getPublicSiteInfoHash } from './utils/public-site-info.ts'

// the site title is injected as text into the served HTML, it must not be able to break out of its tag.
// it must also survive the micro-template passes that follow: '{' is neutralized so a title cannot
// smuggle a later placeholder (CSP_NONCE is substituted after us), and '$' is doubled because
// microTemplate interpolates through String.replace, where $&, $` and $' are replacement patterns.
const escapeHtml = (value: string) => value
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\{/g, '&#123;')
.replace(/\$/g, '$$$$')
import { getSiteExtraParams } from './sites/spa-params.ts'

const app = express()
export default app
Expand Down Expand Up @@ -122,16 +111,7 @@ app.use(tokens)
if (process.env.NODE_ENV !== 'test') {
app.use(await createSpaMiddleware(resolve(import.meta.dirname, '../../ui/dist'), uiConfig, {
csp: { nonce: true, header: true },
getSiteExtraParams: async (siteUrl: string) => {
const site = await getSiteByUrl(siteUrl)
return {
THEME_CSS_HASH: site ? getThemeCssHash(site) : defaultThemeCssHash,
PUBLIC_SITE_INFO_HASH: site ? getPublicSiteInfoHash(site) : defaultPublicSiteInfoHash,
// the SPA sets the definitive title, this one fills the <title> of the served
// document, which the W3C validator requires (RGAA 8.2)
SITE_TITLE: escapeHtml(site?.title || 'Simple Directory')
}
}
getSiteExtraParams
}))
}

Expand Down
30 changes: 23 additions & 7 deletions api/src/mails/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import config from '#config'
import { flatten } from 'flat'
import EventEmitter from 'node:events'
import mailsTransport from './transport.ts'
import { getSiteByUrl, getSiteByHost } from '#services'
import { getSiteByUrl, getSiteByHost, isMainSiteDoc } from '#services'
import { getEffectiveMainSite } from '../sites/main-site.ts'
import { internalError } from '@data-fair/lib-node/observer.js'
import { mailLimiter } from '../utils/limiter.ts'

Expand Down Expand Up @@ -108,19 +109,34 @@ export const sendMail = async (to: string, params: SendMailParams, attachments?:
if (!site && params.host) {
site = await getSiteByHost(params.host, params.path ?? '')
}
// on the main host the document is only honoured through the category list,
// never read raw: this path used to bypass reqSite() entirely
const mainSite = !!site && isMainSiteDoc(site)
if (mainSite) site = undefined

const flatTheme: FlatTheme = flatten({ theme: config.theme })
let logo = config.theme.logo || 'https://cdn.rawgit.com/koumoul-dev/simple-directory/v0.12.3/public/assets/logo-150x150.png'
let from = config.mails.from
let contact = config.contact
// the main site keeps the main template in both cases — it *is* the main
// site, only its colours and sender can change
let template = params.htmlButton ? mainSiteTemplate : mainSiteNoButtonTemplate
if (site?.mails?.from) {
from = site.mails.from
Object.assign(flatTheme, flatten({ theme: site.theme }))
logo = site.theme.logo || logo
template = params.htmlButton ? genericTemplate : genericNoButtonTemplate

if (mainSite) {
const effectiveSite = await getEffectiveMainSite()
Object.assign(flatTheme, flatten({ theme: effectiveSite.theme }))
logo = effectiveSite.theme.logo || logo
from = effectiveSite.mails?.from ?? from
contact = effectiveSite.mails?.contact ?? contact
} else {
if (site?.mails?.from) {
from = site.mails.from
Object.assign(flatTheme, flatten({ theme: site.theme }))
logo = site.theme.logo || logo
template = params.htmlButton ? genericTemplate : genericNoButtonTemplate
}
if (site?.mails?.contact) contact = site.mails.contact
}
if (site?.mails?.contact) contact = site.mails.contact

const tmplParams: SendMailTmplParams = {
...params,
Expand Down
14 changes: 13 additions & 1 deletion api/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { resolve } from 'node:path'
import { createServer } from 'node:http'
import { session } from '@data-fair/lib-express/index.js'
import { startObserver, stopObserver } from '@data-fair/lib-node/observer.js'
import { startObserver, stopObserver, internalError } from '@data-fair/lib-node/observer.js'
import locks from '@data-fair/lib-node/locks.js'
import upgradeScripts from '@data-fair/lib-node/upgrade-scripts.js'
import mongo from '#mongo'
Expand All @@ -18,6 +18,8 @@ import config from '#config'
import * as eventsQueue from '#events-queue'
import { publicGlobalProviders } from './auth/providers.ts'
import { getSiteColorsWarnings } from '@data-fair/lib-common-types/theme/index.js'
import { getMainSiteDoc } from './sites/service.ts'
import { getMainSiteWarnings, getMainSiteIgnoredFields } from './sites/main-site.ts'

const server = createServer(app)
const httpTerminator = createHttpTerminator({ server })
Expand Down Expand Up @@ -56,6 +58,16 @@ export const start = async () => {
for (const cw of colorWarnings) console.error(' - ' + cw)
}

const mainSiteDoc = await getMainSiteDoc()
if (mainSiteDoc) {
console.log(`Main site document ${mainSiteDoc._id} found on ${mainSiteDoc.host}${mainSiteDoc.path ?? ''}; categories read from the database: ${config.mainSiteFromDb.join(', ') || '(none)'}`)
for (const w of getMainSiteWarnings(config.i18n.defaultLocale, mainSiteDoc)) console.warn(' - ' + w)
const ignoredFields = getMainSiteIgnoredFields(mainSiteDoc)
if (ignoredFields.length) {
internalError('main-site-ignored-fields', `main site document ${mainSiteDoc._id} carries fields that are never honoured on the main host: ${ignoredFields.join(', ')}`)
}
}

server.listen(config.port)
await new Promise(resolve => server.once('listening', resolve))

Expand Down
2 changes: 1 addition & 1 deletion api/src/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export * from './mails/service.ts'
export { initOidcProvider, oauthGlobalProviders, getOidcProviderId, getOAuthProviderById, getOAuthProviderByState } from './oauth/service.ts'
export * from './oauth-tokens/service.ts'
export { saml2ServiceProvider, saml2GlobalProviders, getSamlProviderId, getSamlConfigId, getSamlProviderById } from './saml2/service.ts'
export { reqSite, getSiteByUrl, getRedirectSite, getSiteBaseUrl, getSiteByHost, reqAccountMainSite, resolveAccountMainSite } from './sites/service.ts'
export { reqSite, getSiteByUrl, getRedirectSite, getSiteBaseUrl, getSiteByHost, reqAccountMainSite, resolveAccountMainSite, isMainSiteDoc } from './sites/service.ts'
export * from './tokens/service.ts'
export * from './utils/passwords.ts'
export * from './utils/partners.ts'
Expand Down
104 changes: 104 additions & 0 deletions api/src/sites/main-site.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import config from '#config'
import { type Site } from '#types'
import { getMessage } from '#i18n'
import { getMainSiteDoc } from './service.ts'
import { type EffectiveSite, envMainSite, clearPublicSiteInfoHashCache } from '../utils/public-site-info.ts'
import { clearThemeCssHashCache } from '../utils/theme.ts'

type MainSiteCategory = 'theme' | 'title' | 'mails' | 'registration'

const mainSiteCategories: MainSiteCategory[] = ['theme', 'title', 'mails', 'registration']

// Fields of a site document that are never honoured on the main host, whatever
// config.mainSiteFromDb contains. They are inert here, not refused: the API
// blocks no write (see docs/architecture/main-site-config.md for why a write
// barrier was rejected).
const mainSiteIgnoredFields = ['authMode', 'authOnlyOtherSite', 'authProviders', 'applications', 'isAccountMain'] as const

const mainSiteCategoryFields: Record<MainSiteCategory, (keyof Site)[]> = {
theme: ['theme'],
title: ['title'],
mails: ['mails'],
registration: ['tosMessage', 'reducedPersonalInfoAtCreation']
}

// read config at call time, not at module load: tests mutate it through
// PATCH /api/test-env/config
const enabled = (category: MainSiteCategory) => config.mainSiteFromDb.includes(category)

/**
* The main site as it should be served: env config, with the presentation
* fields of its document overlaid for each enabled category.
*
* The result is an ordinary EffectiveSite, so every consumer runs it through
* the same getPublicSiteInfo / getThemeCss / hash functions as a real site —
* there is no parallel rendering path for the main host.
*/
export const getEffectiveMainSite = async (): Promise<EffectiveSite> => {
const site = envMainSite()
const doc = await getMainSiteDoc()
if (!doc) return site

const used: MainSiteCategory[] = []
if (enabled('theme') && doc.theme) {
site.theme = doc.theme
// unlike an ordinary site the owner avatar is the last resort, not the
// first: the main site's identity is the operator's, not the owner org's
if (!site.theme.logo && !config.theme.logo) {
site.theme = { ...site.theme, logo: `/simple-directory/api/avatars/${doc.owner.type}/${doc.owner.id}/avatar.png` }
}
used.push('theme')
}
if (enabled('title') && doc.title) {
site.title = doc.title
used.push('title')
}
if (enabled('mails') && doc.mails) {
site.mails = {
from: doc.mails.from ?? site.mails?.from,
contact: doc.mails.contact ?? site.mails?.contact
}
used.push('mails')
}
if (enabled('registration') && (doc.tosMessage !== undefined || doc.reducedPersonalInfoAtCreation !== undefined)) {
site.tosMessage = doc.tosMessage
site.reducedPersonalInfoAtCreation = doc.reducedPersonalInfoAtCreation
used.push('registration')
}

// the shared hash caches key on _id + updatedAt; fold the contributing
// categories into the id so the key also changes when the category list does
if (used.length) {
site._id = `_main-${doc._id}-${used.join(',')}`
site.updatedAt = doc.updatedAt
}
return site
}

// Only needed by tests, which flip config.mainSiteFromDb at runtime — the
// shared hash caches key on _id + updatedAt and cannot see that change.
export const clearSiteResourceCaches = () => {
clearPublicSiteInfoHashCache()
clearThemeCssHashCache()
}

// Fields the document carries that have no effect on the main host.
export const getMainSiteIgnoredFields = (site: Site): string[] =>
mainSiteIgnoredFields.filter(field => site[field] !== undefined)

// Human readable report for the admin UI and the boot check. Nothing here
// blocks a write: the admin form round-trips the whole document, so a write
// barrier would reject an idempotent save. See
// docs/architecture/main-site-config.md
export const getMainSiteWarnings = (localeCode: string, site: Site): string[] => {
const warnings: string[] = []
for (const field of getMainSiteIgnoredFields(site)) {
warnings.push(getMessage(localeCode, 'mainSite.ignoredField', { field }))
}
for (const category of mainSiteCategories) {
if (config.mainSiteFromDb.includes(category)) continue
if (!mainSiteCategoryFields[category].some(field => site[field] !== undefined)) continue
warnings.push(getMessage(localeCode, 'mainSite.ignoredCategory', { category }))
}
return warnings
}
Loading
Loading