diff --git a/AGENTS.md b/AGENTS.md
index 9ba6277e..056b3488 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/api/config/custom-environment-variables.cjs b/api/config/custom-environment-variables.cjs
index fad244c8..2b0f4cc2 100644
--- a/api/config/custom-environment-variables.cjs
+++ b/api/config/custom-environment-variables.cjs
@@ -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',
diff --git a/api/config/default.cjs b/api/config/default.cjs
index b23791b5..9406a676 100644
--- a/api/config/default.cjs
+++ b/api/config/default.cjs
@@ -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,
diff --git a/api/config/type/schema.json b/api/config/type/schema.json
index 5c49ad2f..c76865d3 100644
--- a/api/config/type/schema.json
+++ b/api/config/type/schema.json
@@ -45,7 +45,8 @@
"cleanup",
"avatars",
"noBirthday",
- "passwordValidation"
+ "passwordValidation",
+ "mainSiteFromDb"
],
"properties": {
"info": {
@@ -212,6 +213,13 @@
"acceptUnknownSite": {
"type": "boolean"
},
+ "mainSiteFromDb": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["theme", "title", "mails", "registration"]
+ }
+ },
"managePartners": {
"type": "boolean"
},
diff --git a/api/i18n/en.js b/api/i18n/en.js
index 8ffef85b..e3892df5 100644
--- a/api/i18n/en.js
+++ b/api/i18n/en.js
@@ -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.',
@@ -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: {
@@ -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.'
}
}
diff --git a/api/i18n/fr.js b/api/i18n/fr.js
index d9280425..1bb8876c 100644
--- a/api/i18n/fr.js
+++ b/api/i18n/fr.js
@@ -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.',
@@ -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: {
@@ -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.'
}
}
diff --git a/api/src/app.ts b/api/src/app.ts
index ab732ca6..cdee3ceb 100644
--- a/api/src/app.ts
+++ b/api/src/app.ts
@@ -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, '&').replace(//g, '>')
- .replace(/\{/g, '{')
- .replace(/\$/g, '$$$$')
+import { getSiteExtraParams } from './sites/spa-params.ts'
const app = express()
export default app
@@ -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
of the served
- // document, which the W3C validator requires (RGAA 8.2)
- SITE_TITLE: escapeHtml(site?.title || 'Simple Directory')
- }
- }
+ getSiteExtraParams
}))
}
diff --git a/api/src/mails/service.ts b/api/src/mails/service.ts
index a190f8dd..fa06ced7 100644
--- a/api/src/mails/service.ts
+++ b/api/src/mails/service.ts
@@ -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'
@@ -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,
diff --git a/api/src/server.ts b/api/src/server.ts
index c969343c..d9320455 100644
--- a/api/src/server.ts
+++ b/api/src/server.ts
@@ -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'
@@ -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 })
@@ -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))
diff --git a/api/src/services.ts b/api/src/services.ts
index e0ccffd7..5bc25d7b 100644
--- a/api/src/services.ts
+++ b/api/src/services.ts
@@ -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'
diff --git a/api/src/sites/main-site.ts b/api/src/sites/main-site.ts
new file mode 100644
index 00000000..f4139500
--- /dev/null
+++ b/api/src/sites/main-site.ts
@@ -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 = {
+ 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 => {
+ 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
+}
diff --git a/api/src/sites/router.ts b/api/src/sites/router.ts
index 848470aa..173585df 100644
--- a/api/src/sites/router.ts
+++ b/api/src/sites/router.ts
@@ -4,8 +4,8 @@ import config from '#config'
import { reqUser, reqUserAuthenticated, reqSiteUrl, httpError, reqSessionAuthenticated, type AccountKeys } from '@data-fair/lib-express'
import { nanoid } from 'nanoid'
import { findAllSites, findOwnerSites, patchSite, deleteSite, getSite, toggleMainSite, findMainSite } from './service.ts'
-import { getThemeCss, getThemeCssHash, defaultThemeCssHash, defaultThemeCss } from '../utils/theme.ts'
-import { isOIDCProvider, reqSite } from '#services'
+import { getThemeCss, getThemeCssHash } from '../utils/theme.ts'
+import { isOIDCProvider, reqSite, isMainSiteDoc } from '#services'
import { reqI18n } from '#i18n'
import { getOidcProviderId } from '../oauth/oidc.ts'
import { getSiteColorsWarnings, fillTheme } from '@data-fair/lib-common-types/theme/index.js'
@@ -13,7 +13,8 @@ import clone from '@data-fair/lib-utils/clone.js'
import Debug from 'debug'
import { cipher } from '../utils/cipher.ts'
import { type OpenIDConnect } from '#types/site/index.ts'
-import { defaultPublicSiteInfo, defaultPublicSiteInfoHash, getPublicSiteInfo, getPublicSiteInfoHash } from '../utils/public-site-info.ts'
+import { getPublicSiteInfo, getPublicSiteInfoHash } from '../utils/public-site-info.ts'
+import { getEffectiveMainSite, getMainSiteWarnings } from './main-site.ts'
import serialize from 'serialize-javascript'
const debugPostSite = Debug('post-site')
@@ -37,9 +38,10 @@ const prepareFullSite = (req: Request, site: Site) => {
}
}
}
- const resultWithColorWarnings: any = site as any
+ const resultWithWarnings: any = site as any
const { localeCode } = reqI18n(req)
- resultWithColorWarnings.colorWarnings = getSiteColorsWarnings(localeCode as 'fr' | 'en', site.theme, site.authProviders as { title?: string, color?: string }[])
+ resultWithWarnings.colorWarnings = getSiteColorsWarnings(localeCode as 'fr' | 'en', site.theme, site.authProviders as { title?: string, color?: string }[])
+ resultWithWarnings.mainSiteWarnings = isMainSiteDoc(site) ? getMainSiteWarnings(localeCode, site) : []
}
router.get('', async (req, res, next) => {
@@ -191,7 +193,10 @@ router.patch('/:id', async (req, res, next) => {
const patchedSite = await patchSite({ _id: req.params.id, updatedAt: new Date().toISOString(), ...patch })
- if (patch.isAccountMain) {
+ // isAccountMain is inert on the main site document (it already *is* the main
+ // site), and the admin form round-trips the whole document, so firing
+ // toggleMainSite here would rewrite the owner's other sites on every save
+ if (patch.isAccountMain && !isMainSiteDoc(patchedSite)) {
// toggle the main site
await toggleMainSite(patchedSite)
}
@@ -211,16 +216,15 @@ router.get('/_public', async (req, res, next) => {
res.setHeader('Cache-Control', 'public, max-age=60')
// force buffering (necessary for caching) of this response in the reverse proxy
res.setHeader('X-Accel-Buffering', 'yes')
- const site = await reqSite(req)
- const publicSiteInfo = site ? await getPublicSiteInfo(site) : defaultPublicSiteInfo
- res.send(publicSiteInfo)
+ const site = await reqSite(req) ?? await getEffectiveMainSite()
+ res.send(getPublicSiteInfo(site))
})
router.get('/_public.js', async (req, res, next) => {
res.setHeader('Cache-Control', 'public, max-age=60')
// force buffering (necessary for caching) of this response in the reverse proxy
res.setHeader('X-Accel-Buffering', 'yes')
- const site = await reqSite(req)
- const publicSiteInfo = site ? await getPublicSiteInfo(site) : defaultPublicSiteInfo
+ const site = await reqSite(req) ?? await getEffectiveMainSite()
+ const publicSiteInfo = getPublicSiteInfo(site)
res.contentType('application/javascript')
res.send(`window.__PUBLIC_SITE_INFO=${serialize(publicSiteInfo)}`)
})
@@ -228,23 +232,23 @@ router.get('/:hash/_public.js', async (req, res, next) => {
res.setHeader('Cache-Control', `public, max-age=${hashedMaxAge}, immutable`)
// force buffering (necessary for caching) of this response in the reverse proxy
res.setHeader('X-Accel-Buffering', 'yes')
- const site = await reqSite(req)
- const publicSiteInfo = site ? await getPublicSiteInfo(site) : defaultPublicSiteInfo
+ const site = await reqSite(req) ?? await getEffectiveMainSite()
+ const publicSiteInfo = getPublicSiteInfo(site)
// TODO: fail if hash doesn't match ?
res.contentType('application/javascript')
res.send(`window.__PUBLIC_SITE_INFO=${serialize(publicSiteInfo)}`)
})
router.get('/_default_theme', async (req, res, next) => {
- res.send(config.theme)
+ res.send((await getEffectiveMainSite()).theme)
})
router.get('/_theme.css', async (req, res, next) => {
res.setHeader('Cache-Control', 'public, max-age=60')
// force buffering (necessary for caching) of this response in the reverse proxy
res.setHeader('X-Accel-Buffering', 'yes')
- const site = await reqSite(req)
- const css = site ? getThemeCss(site.theme, site.path ?? '') : defaultThemeCss
+ const site = await reqSite(req) ?? await getEffectiveMainSite()
+ const css = getThemeCss(site.theme, site.path ?? '')
res.contentType('css')
res.send(css)
})
@@ -252,25 +256,25 @@ router.get('/:hash/_theme.css', async (req, res, next) => {
res.setHeader('Cache-Control', `public, max-age=${hashedMaxAge}, immutable`)
// force buffering (necessary for caching) of this response in the reverse proxy
res.setHeader('X-Accel-Buffering', 'yes')
- const site = await reqSite(req)
+ const site = await reqSite(req) ?? await getEffectiveMainSite()
// TODO: fail if hash doesn't match ?
- const css = site ? getThemeCss(site.theme, site.path ?? '') : defaultThemeCss
+ const css = getThemeCss(site.theme, site.path ?? '')
res.contentType('css')
res.send(css)
})
router.get('/_hashes', async (req, res, next) => {
- const site = await reqSite(req)
+ const site = await reqSite(req) ?? await getEffectiveMainSite()
res.send({
- publicInfo: site ? getPublicSiteInfoHash(site) : defaultPublicSiteInfoHash,
- themeCss: site ? getThemeCssHash(site) : defaultThemeCssHash,
- preloadLinks: site?.theme.preloadLinks ?? config.theme.preloadLinks ?? []
+ publicInfo: getPublicSiteInfoHash(site),
+ themeCss: getThemeCssHash(site),
+ preloadLinks: site.theme.preloadLinks ?? config.theme.preloadLinks ?? []
})
})
router.get('/:id/_theme_warnings', async (req, res, next) => {
- const site = await reqSite(req)
+ const site = await reqSite(req) ?? await getEffectiveMainSite()
const { localeCode } = reqI18n(req)
- res.send(getSiteColorsWarnings(localeCode as 'fr' | 'en', site?.theme ?? config.theme, site?.authProviders as { title?: string, color?: string }[]))
+ res.send(getSiteColorsWarnings(localeCode as 'fr' | 'en', site.theme, site.authProviders as { title?: string, color?: string }[]))
})
router.get('/:id', async (req, res, next) => {
diff --git a/api/src/sites/service.ts b/api/src/sites/service.ts
index bcd8aa96..934f8fa4 100644
--- a/api/src/sites/service.ts
+++ b/api/src/sites/service.ts
@@ -27,6 +27,24 @@ export const getSiteBaseUrl = (site: Site) => {
return `${publicUrl.protocol}//${site.host}${site.path ?? ''}`
}
+// The "main site document" is a site doc whose (host, path) matches publicUrl.
+// It is honoured for presentation only — reqSite() below still returns
+// undefined on that host, so identity and trust rules are untouched.
+// See docs/architecture/main-site-config.md
+export const isMainSiteUrl = (siteUrl: string) => config.publicUrl.startsWith(siteUrl)
+
+export const isMainSiteDoc = (site: Pick) =>
+ isMainSiteUrl(`${publicUrl.protocol}//${site.host}${site.path ?? ''}`)
+
+export const getMainSiteDoc = memoize(async (): Promise => {
+ if (!config.manageSites) return undefined
+ const sites = await mongo.sites.find({ host: publicUrl.host }).toArray()
+ return sites.find(isMainSiteDoc)
+}, {
+ promise: true,
+ maxAge: 2000 // 2s, same as getSiteByHost
+})
+
export const getRedirectSite = async (req: Request, redirect: string) => {
const currentSiteUrl = reqSiteUrl(req)
const currentSite = await reqSite(req)
diff --git a/api/src/sites/spa-params.ts b/api/src/sites/spa-params.ts
new file mode 100644
index 00000000..2510db0c
--- /dev/null
+++ b/api/src/sites/spa-params.ts
@@ -0,0 +1,34 @@
+import { getSiteByUrl, isMainSiteUrl } from './service.ts'
+import { getThemeCssHash } from '../utils/theme.ts'
+import { getPublicSiteInfoHash } from '../utils/public-site-info.ts'
+import { getEffectiveMainSite } from './main-site.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, '&').replace(//g, '>')
+ .replace(/\{/g, '{')
+ .replace(/\$/g, '$$$$')
+
+/**
+ * Values injected into the served index.html for a given site URL.
+ *
+ * This mirrors reqSite(): on the main host the document is never read raw, it
+ * goes through getEffectiveMainSite like everywhere else. Reading it raw here
+ * while the /api/sites/_* endpoints read env is what made the immutable
+ * theme-css cache key describe bytes it was not serving — the hash came from
+ * the document, the CSS came from the environment, and the mismatch was cached
+ * for a year.
+ */
+export const getSiteExtraParams = async (siteUrl: string) => {
+ const site = (isMainSiteUrl(siteUrl) ? undefined : await getSiteByUrl(siteUrl)) ?? await getEffectiveMainSite()
+ return {
+ THEME_CSS_HASH: getThemeCssHash(site),
+ PUBLIC_SITE_INFO_HASH: getPublicSiteInfoHash(site),
+ // the SPA sets the definitive title, this one fills the of the
+ // served document, which the W3C validator requires (RGAA 8.2)
+ SITE_TITLE: escapeHtml(site.title || 'Simple Directory')
+ }
+}
diff --git a/api/src/test-env.ts b/api/src/test-env.ts
index a4c092a3..dfcf74e2 100644
--- a/api/src/test-env.ts
+++ b/api/src/test-env.ts
@@ -53,6 +53,15 @@ router.delete('/', async (req, res) => {
await mongo.passwordLists.deleteMany()
await mongo.db.collection('sd-rate-limiter-auth').deleteMany()
await mongo.db.collection('sd-rate-limiter-contact').deleteMany()
+ // the per-recipient mail budget is the one limiter whose window outlives a
+ // test run: the dev server runs with NODE_ENV=development, so it uses the
+ // production default of 500 mails per recipient per DAY rather than the tiny
+ // test.cjs window. Shared fixture addresses (admin@test.com, user@test.com)
+ // burn that budget a few mails per run and eventually exhaust it, which
+ // surfaces as an unrelated `waitForMail timeout` somewhere else entirely.
+ // mails-rate-limit.api.spec.ts is unaffected: it pre-fills its own bucket
+ // inside each test, with a unique recipient, after this cleanup has run.
+ await mongo.db.collection('sd-rate-limiter-mail').deleteMany()
const { getSiteByHost } = await import('./sites/service.ts')
getSiteByHost.clear()
// Force a fresh SAML cert mint on the next request — exercises createCert end-to-end
@@ -148,10 +157,14 @@ router.post('/run-user-cleanup', async (req, res) => {
res.status(200).send('ok')
})
-// POST /api/test-env/clear-site-cache — clear the getSiteByHost memoized cache
+// POST /api/test-env/clear-site-cache — clear the memoized site lookups and
+// the derived main-site resources
router.post('/clear-site-cache', async (req, res) => {
- const { getSiteByHost } = await import('./sites/service.ts')
+ const { getSiteByHost, getMainSiteDoc } = await import('./sites/service.ts')
+ const { clearSiteResourceCaches } = await import('./sites/main-site.ts')
getSiteByHost.clear()
+ getMainSiteDoc.clear()
+ clearSiteResourceCaches()
res.status(200).send('ok')
})
diff --git a/api/src/utils/public-site-info.ts b/api/src/utils/public-site-info.ts
index 0960dd36..5c0c9e3f 100644
--- a/api/src/utils/public-site-info.ts
+++ b/api/src/utils/public-site-info.ts
@@ -24,12 +24,43 @@ const lighterTheme = (fullTheme: Theme) => {
return theme
}
+/**
+ * A site as it is served. Either a real document, or the synthetic main site
+ * built by sites/main-site.ts from env config overlaid with its document.
+ *
+ * The main site has no owner (its identity is the operator's, not an
+ * organization's), so `owner` is optional here where the stored Site requires
+ * it. Everything downstream treats both the same way.
+ */
+export type EffectiveSite = Omit & { owner?: Site['owner'], main?: true }
+
const publicHost = new URL(config.publicUrl).host
-export const getPublicSiteInfo = (site: Site): SitePublic => {
+
+// The env-only main site. Kept free of any mongo access: ui/vite.config.ts
+// imports defaultPublicSiteInfoHash below to inject the dev server's HTML.
+// `path` is deliberately left undefined so the generated theme css keeps the
+// empty SITE_PATH it has always used, even on a prefixed publicUrl.
+export const envMainSite = (): EffectiveSite => ({
+ _id: '_main',
+ main: true,
+ host: publicHost,
+ theme: config.theme,
+ mails: { from: config.mails.from, contact: config.contact },
+ isAccountMain: true,
+ authMode: 'onlyLocal'
+})
+
+export const getPublicSiteInfo = (site: EffectiveSite): SitePublic => {
const authMode = site.authMode ?? 'onlyBackOffice'
let authOnlyOtherSite = site.authOnlyOtherSite
if (authMode === 'onlyBackOffice') authOnlyOtherSite = publicHost
+ // an ordinary site falls back to its owner's avatar; the main site arrives
+ // with its logo already resolved and has no owner to fall back to
+ const logo = site.theme.logo || (site.owner && `/simple-directory/api/avatars/${site.owner.type}/${site.owner.id}/avatar.png`)
return {
+ // only emitted for the main site, so an ordinary site's payload — and
+ // therefore its hash — is byte for byte what it was before
+ ...(site.main ? { main: true } : {}),
host: site.host,
path: site.path,
tmp: site.tmp,
@@ -37,28 +68,27 @@ export const getPublicSiteInfo = (site: Site): SitePublic => {
title: site.title,
isAccountMain: site.isAccountMain,
tosMessage: site.tosMessage,
+ reducedPersonalInfoAtCreation: site.reducedPersonalInfoAtCreation,
theme: {
...lighterTheme(site.theme ?? config.theme),
- logo: site.theme.logo || `/simple-directory/api/avatars/${site.owner.type}/${site.owner.id}/avatar.png`
+ ...(logo ? { logo } : {})
},
authMode,
authOnlyOtherSite
- }
+ } as SitePublic
}
const publicSiteInfoHashCache: Record = {}
-export const getPublicSiteInfoHash = (site: Site) => {
+export const getPublicSiteInfoHash = (site: EffectiveSite) => {
const publicInfo = getPublicSiteInfo(site)
const cacheKey = site?._id + '-' + site?.updatedAt
publicSiteInfoHashCache[cacheKey] = publicSiteInfoHashCache[cacheKey] ?? crypto.createHash('md5').update(serialize(publicInfo)).digest('hex')
return publicSiteInfoHashCache[cacheKey]
}
-export const defaultPublicSiteInfo = {
- main: true,
- host: publicHost,
- theme: lighterTheme(config.theme),
- isAccountMain: true,
- authMode: 'onlyLocal',
+export const clearPublicSiteInfoHashCache = () => {
+ for (const key of Object.keys(publicSiteInfoHashCache)) delete publicSiteInfoHashCache[key]
}
+
+export const defaultPublicSiteInfo = getPublicSiteInfo(envMainSite())
export const defaultPublicSiteInfoHash = crypto.createHash('md5').update(serialize(defaultPublicSiteInfo)).digest('hex')
diff --git a/api/src/utils/theme.ts b/api/src/utils/theme.ts
index 48ff2a63..f4dbdcff 100644
--- a/api/src/utils/theme.ts
+++ b/api/src/utils/theme.ts
@@ -1,6 +1,6 @@
import config from '../config.ts'
import crypto from 'node:crypto'
-import { type Site } from '../../types/index.ts'
+import { type EffectiveSite } from './public-site-info.ts'
import microTemplate from '@data-fair/lib-utils/micro-template.js'
import { getTextColorsCss, type Theme } from '@data-fair/lib-common-types/theme/index.js'
@@ -29,11 +29,15 @@ export const getThemeCss = (theme: Theme, sitePath: string = '') => {
}
const themeCssHashCache: Record = {}
-export const getThemeCssHash = (site: Site) => {
+export const getThemeCssHash = (site: EffectiveSite) => {
const cacheKey = site._id + '-' + site.updatedAt
themeCssHashCache[cacheKey] = themeCssHashCache[cacheKey] ?? crypto.createHash('md5').update(getThemeCss(site.theme, site.path)).digest('hex')
return themeCssHashCache[cacheKey]
}
+export const clearThemeCssHashCache = () => {
+ for (const key of Object.keys(themeCssHashCache)) delete themeCssHashCache[key]
+}
+
export const defaultThemeCss = getThemeCss(config.theme)
export const defaultThemeCssHash = crypto.createHash('md5').update(defaultThemeCss).digest('hex')
diff --git a/api/types/site/schema.js b/api/types/site/schema.js
index ec0dc3da..b4450e9a 100644
--- a/api/types/site/schema.js
+++ b/api/types/site/schema.js
@@ -111,6 +111,10 @@ export default {
},
isAccountMain: {
type: 'boolean',
+ // inert on the main site document (it already *is* the main site), and
+ // the admin form round-trips the whole document, so offering it there
+ // would invite a save that rewrites the owner's other sites
+ layout: { if: '!context.isMainSite' },
title: 'Site principal du compte',
'x-i18n-title': {
fr: 'Site principal du compte',
diff --git a/dev/fixtures.ts b/dev/fixtures.ts
index 643609a6..d9de3e92 100644
--- a/dev/fixtures.ts
+++ b/dev/fixtures.ts
@@ -32,6 +32,7 @@ import { axios, axiosAuth, waitForMail, testEnvAx, getServerConfig } from '../te
const EMAIL_DOMAIN = 'dev-fixtures.org'
const PASSWORD = 'TestPasswd01'
const SITE_ID = 'dev-fixtures-portal'
+const MAIN_SITE_ID = 'dev-fixtures-main'
const CORP_NAME = 'Dev Fixtures Corp'
const PARTNER_NAME = 'Dev Fixtures Partner'
const MEMBERS_LIMIT = 10
@@ -270,6 +271,36 @@ const main = async () => {
console.log(` ~ site ${SITE_ID} on http://${siteHost}/simple-directory (ssoBackOffice)`)
console.log(' note: a test run wipes the sites collection, re-run this script to get it back')
+ // the main site document: a site on the publicUrl host. It drives
+ // presentation only, and only for the categories in MAIN_SITE_FROM_DB —
+ // which is empty by default, so nothing visibly changes until you set
+ // MAIN_SITE_FROM_DB='["theme","title","mails","registration"]' in .env.
+ // See docs/architecture/main-site-config.md
+ const mainSiteHost = new URL(config.publicUrl).host
+ const mainSquatter = allSites.results.find((s: any) => s.host === mainSiteHost && s._id !== MAIN_SITE_ID)
+ if (mainSquatter) {
+ if (!mainSquatter._id.startsWith('test_')) {
+ throw new Error(`site ${mainSquatter._id} already uses host ${mainSiteHost}, refusing to touch it — delete it or free the host first`)
+ }
+ await anonymousAx.delete(`/api/sites/${mainSquatter._id}`, { params: { key: config.secretKeys.sites } })
+ console.log(` - removed leftover test site ${mainSquatter._id} from ${mainSiteHost}`)
+ }
+ await anonymousAx.post('/api/sites', {
+ _id: MAIN_SITE_ID,
+ owner: { type: 'organization', id: corp.id, name: corp.name },
+ host: mainSiteHost,
+ title: 'Annuaire Dev Fixtures',
+ theme: { primaryColor: '#6A1B9A' }
+ }, { params: { key: config.secretKeys.sites } })
+ await superAdminAx.patch(`/api/sites/${MAIN_SITE_ID}`, {
+ mails: { contact: 'contact-main@fixtures.dev' },
+ tosMessage: 'CGU du site principal (fixtures)'
+ })
+ await testEnvAx.post('/clear-site-cache')
+ console.log(` ~ main site document ${MAIN_SITE_ID} on ${config.publicUrl}`)
+ console.log(` MAIN_SITE_FROM_DB is currently ${process.env.MAIN_SITE_FROM_DB ?? '[]'}; set it in .env to see it take effect`)
+ console.log(' note: a test run wipes the sites collection, re-run this script to get it back')
+
console.log(`\n✔ Fixtures applied. Log in at ${config.publicUrl}/login with ${email('owner')} / ${PASSWORD}`)
}
diff --git a/docs/architecture/main-site-config.md b/docs/architecture/main-site-config.md
new file mode 100644
index 00000000..217aa044
--- /dev/null
+++ b/docs/architecture/main-site-config.md
@@ -0,0 +1,195 @@
+# Main site configuration: database document vs environment
+
+What the main site reads from a database document and what it reads from
+environment variables, and why the API reports rather than refuses. Required
+reading before changing `api/src/sites/main-site.ts`, the `/api/sites/_*`
+presentation endpoints, `getSiteExtraParams` in `api/src/sites/spa-params.ts`,
+or the mail theming path in `api/src/mails/service.ts`.
+
+See also [`email-trust-and-site-isolation.md`](email-trust-and-site-isolation.md)
+for why the main site is a load-bearing security concept.
+
+## The problem this solves
+
+An install whose `publicUrl` host *also* carries a site document in mongo had
+two writable sources of configuration for the same host, and three resolution
+paths disagreeing about which one wins.
+
+**Path A — `reqSite(req)`** (`api/src/sites/service.ts`) is authoritative for
+nearly everything:
+
+```ts
+if (siteUrl && !config.publicUrl.startsWith(siteUrl) && siteUrl !== `http://simple-directory:${config.port}`) {
+ … look the site up in mongo …
+}
+// else: returns undefined
+```
+
+On the `publicUrl` host it returns `undefined` and the document is never even
+looked up, so every consumer falls back to `config.*`.
+
+**Path B — `getSiteByUrl` / `getSiteByHost`** is a raw mongo lookup with no
+`publicUrl` exclusion. Two callers used it instead of `reqSite`: the SPA HTML
+injection and the mail templating.
+
+**Path C — `config.*`**, the environment.
+
+With no document on the `publicUrl` host all three agree and the problem is
+invisible. With one, A and B disagreed *within the same request*.
+
+That produced a live bug: `getSiteExtraParams` read the document for
+`THEME_CSS_HASH` while `GET /api/sites/:hash/_theme.css` called `reqSite` and
+served the **env** CSS — under `Cache-Control: max-age=31536000, immutable`. The
+hash did not describe the bytes served under it, so an env theme change never
+busted the cache and a change to the ignored document busted it for nothing.
+
+## The main site document
+
+A **main site document** is a site doc whose `host` + `path` matches
+`config.publicUrl` (`isMainSiteDoc` in `api/src/sites/service.ts`). It is
+honoured for **presentation only**.
+
+`reqSite()` still returns `undefined` on that host. This is deliberate and
+load-bearing: `reqSite() === undefined` *is* the definition of "main site"
+across the trust model.
+
+- `isAdmin = !user.host` — storage rule, invariant #1
+- `getUserByEmail(email, undefined)` filters `host: {$exists: false}`
+- `adminMode` is refused on any session where `reqSite()` returns a site —
+ invariant #2
+
+Making `reqSite` return the document would scope every main-host account to
+that host: existing unscoped users become unreachable and no session can obtain
+`adminMode`. Nothing under `api/src/auth/`, `api/src/tokens/` or
+`api/src/storages/` participates in this feature.
+
+When `config.manageSites` is false there is never a main site document.
+
+## `MAIN_SITE_FROM_DB`
+
+`config.mainSiteFromDb` lists which categories come from the document.
+Environment: `MAIN_SITE_FROM_DB='["theme","title","mails","registration"]'`. The
+items are constrained by an `enum` in `api/config/type/schema.json`, so an
+unknown category refuses to start.
+
+| Category | Site fields | Env fallback |
+|---|---|---|
+| `theme` | `theme` (colors, logo, fonts, `preloadLinks`) | `config.theme` |
+| `title` | `title` | `'Simple Directory'` |
+| `mails` | `mails.from`, `mails.contact` | `config.mails.from`, `config.contact` |
+| `registration` | `tosMessage`, `reducedPersonalInfoAtCreation` | none |
+
+Fallback is per-category, not per-key: a document's `theme` is already
+`fillTheme`'d against `config.theme` when written, so a stored theme is always
+complete.
+
+Logo order on the main site is `doc.theme.logo` → `config.theme.logo` → the
+document owner's avatar. Unlike an ordinary site the owner avatar is the last
+resort, because the main site's identity is the operator's, not the owning
+organisation's.
+
+**Never read from the document, whatever the list contains:** `authMode`,
+`authOnlyOtherSite`, `authProviders`, `applications`, `isAccountMain`, `owner`,
+and user scoping. `getPublicSiteInfo` for the main site forces `main: true`,
+`isAccountMain: true` and `authMode: 'onlyLocal'`.
+
+The default is `[]` in 8.x, so no existing install changes behaviour on
+upgrade. 9.0 will default to the full list.
+
+## One merge, no second rendering path
+
+`api/src/sites/main-site.ts` owns the merge and nothing else. Its only job is
+`getEffectiveMainSite()`: take the env baseline and overlay the document's
+presentation fields for each enabled category. What it returns is an ordinary
+`EffectiveSite`, so every consumer renders it with the **same** functions it
+uses for a real site — there is deliberately no main-site variant of
+`getPublicSiteInfo`, `getThemeCss` or the hash caches:
+
+```ts
+const site = await reqSite(req) ?? await getEffectiveMainSite()
+res.send(getPublicSiteInfo(site))
+```
+
+That shape holds in the `/api/sites/_*` endpoints
+(`api/src/sites/router.ts`), in `getSiteExtraParams`
+(`api/src/sites/spa-params.ts`) and in `api/src/mails/service.ts`. Because the
+renderer is shared, paths A and B cannot disagree and the hashes cannot stop
+describing the bytes served under them.
+
+`EffectiveSite` (`api/src/utils/public-site-info.ts`) is `Site` with an optional
+`owner` and a `main?: true` flag — the main site has no owning organization, and
+`main` is the one key `getPublicSiteInfo` emits conditionally so an ordinary
+site's payload, and therefore its hash, is byte for byte what it was before this
+change.
+
+Two details the merge handles so the shared renderer stays dumb:
+
+- **Logo precedence differs.** An ordinary site falls back to its owner's
+ avatar; the main site prefers `config.theme.logo` and uses the owner avatar
+ only as a last resort. The merge resolves the logo before returning, so
+ `getPublicSiteInfo` only ever reads `theme.logo`.
+- **Cache keys.** The shared hash caches key on `_id + updatedAt`, which cannot
+ see a change to `mainSiteFromDb`. The merge folds the contributing categories
+ into the synthetic `_id` (`_main--`). This only matters for
+ tests, which flip the config at runtime; `clearSiteResourceCaches()` backs
+ `POST /api/test-env/clear-site-cache`.
+
+`envMainSite()` and the `defaultPublicSiteInfo` / `defaultThemeCss` constants
+stay free of any mongo access: `ui/vite.config.ts` imports the hashes to inject
+the dev server's HTML.
+
+### Blast radius beyond simple-directory
+
+`GET /api/sites/_hashes` is what every other data-fair service calls
+(`@data-fair/lib-express/serve-spa.js`, with `x-forwarded-host`). Enabling the
+`theme` category propagates the document's theme to data-fair, processings,
+catalogs, metrics and events served on that host, not only to
+simple-directory's own pages. This is intended.
+
+## The API refuses nothing
+
+An earlier design had `PATCH /api/sites/:id` return 400 on the main document for
+the never-honoured fields. It was rejected, and should not be reintroduced:
+
+- `ui/src/pages/admin/sites/[id].vue` builds its patch as a **full-document
+ round-trip** (it clones the fetched site and deletes only `_id`,
+ `colorWarnings`, `mainSiteWarnings`, `owner`, `host`, `path`). `authMode` is
+ `required` in the Site schema, so it is always present. Changing a single
+ colour on the main document would have returned 400. Hiding the field in the
+ VJSF layout would not help — the key stays in the data.
+- The guard protected nothing. The only non-UI writer is portals
+ (`api/src/portals/service.ts` in the portals repo), which only `POST`s, and
+ `POST`'s body schema is already limited to `_id, owner, host, path, tmp,
+ title, theme` and `contact`.
+
+The risk being managed is operator *confusion*, not privilege: these fields are
+inert here. Confusion is addressed where it occurs, in the admin UI.
+
+**Instead, `mainSiteWarnings`.** `GET /api/sites/:id` and the `showAll` list
+carry it alongside `colorWarnings`, built in `prepareFullSite` and localised
+through `reqI18n`: one entry per never-honoured field the document carries, one
+per category stored but absent from `MAIN_SITE_FROM_DB`. The boot check in
+`api/src/server.ts` logs the same report and raises an `internalError` when the
+document carries an inert field.
+
+**One side effect is suppressed, not refused.** `PATCH` runs `toggleMainSite()`
+whenever `patch.isAccountMain` is truthy, rewriting every *other* site of the
+owner to `authMode: 'onlyOtherSite'`. With a full-document round-trip that
+re-fires on every save, so it is skipped on the main document. The request still
+succeeds; only the effect is skipped, so no caller breaks.
+
+## Admin UI
+
+`/admin/sites` badges the main site document and folds `mainSiteWarnings` into
+the existing warnings menu. Its edit page shows a banner explaining the split
+and lists the warnings.
+
+The auth sections stay **visible**: the form round-trips them, so hiding would
+conceal the values the warnings refer to. Only `isAccountMain` is withheld
+(`layout.if: '!context.isMainSite'` on the property in
+`api/types/site/schema.js`), being the one field whose write reaches beyond the
+document.
+
+Because the patch schema is `additionalProperties: false`, the computed
+`mainSiteWarnings` must be stripped from the patch body like `colorWarnings`
+is — otherwise every save of the main document fails with a 400.
diff --git a/tests/features/main-site-config.unit.spec.ts b/tests/features/main-site-config.unit.spec.ts
new file mode 100644
index 00000000..a054692c
--- /dev/null
+++ b/tests/features/main-site-config.unit.spec.ts
@@ -0,0 +1,30 @@
+// The MAIN_SITE_FROM_DB category list is constrained by an enum in
+// api/config/type/schema.json so that a typo refuses to start the server
+// rather than silently disabling a category.
+
+import { strict as assert } from 'node:assert'
+import { test } from '@playwright/test'
+
+process.env.NODE_CONFIG_DIR = process.env.NODE_CONFIG_DIR || './api/config/'
+process.env.NODE_ENV = process.env.NODE_ENV || 'test'
+process.env.SUPPRESS_NO_CONFIG_WARNING = '1'
+
+test.describe('mainSiteFromDb config', () => {
+ test('defaults to an empty list', async () => {
+ delete process.env.MAIN_SITE_FROM_DB
+ const mod = await import(`../../api/src/config.ts?mainsite-default=${Date.now()}`)
+ assert.deepEqual(mod.default.mainSiteFromDb, [])
+ })
+
+ test('accepts the four known categories', async () => {
+ process.env.MAIN_SITE_FROM_DB = '["theme","title","mails","registration"]'
+ const mod = await import(`../../api/src/config.ts?mainsite-ok=${Date.now()}`)
+ assert.deepEqual(mod.default.mainSiteFromDb, ['theme', 'title', 'mails', 'registration'])
+ })
+
+ test('refuses an unknown category', async () => {
+ process.env.MAIN_SITE_FROM_DB = '["theme","authProviders"]'
+ await assert.rejects(import(`../../api/src/config.ts?mainsite-ko=${Date.now()}`))
+ delete process.env.MAIN_SITE_FROM_DB
+ })
+})
diff --git a/tests/features/main-site.api.spec.ts b/tests/features/main-site.api.spec.ts
new file mode 100644
index 00000000..318beea2
--- /dev/null
+++ b/tests/features/main-site.api.spec.ts
@@ -0,0 +1,207 @@
+// End-to-end behaviour of the main site document over HTTP.
+// The main host is the one in publicUrl; a site document on it drives
+// presentation only, and only for the categories in config.mainSiteFromDb.
+
+import { strict as assert } from 'node:assert'
+import { test } from '@playwright/test'
+import { axios, axiosAuth, testEnvAx, createUser, getServerConfig, maildevAx, deleteAllEmails } from '../support/axios.ts'
+
+const findEmailTo = async (address: string) => {
+ await new Promise(resolve => setTimeout(resolve, 50))
+ const emails: any[] = (await maildevAx.get('/email')).data
+ return emails.find(m => m.envelope?.to?.[0]?.address === address)
+}
+
+const setCategories = async (categories: string[]) => {
+ await testEnvAx.patch('/config', { mainSiteFromDb: categories })
+ await testEnvAx.post('/clear-site-cache')
+}
+
+const seedMainSiteDoc = async () => {
+ const config = await getServerConfig()
+ const mainHost = new URL(config.publicUrl).host
+ const { ax } = await createUser('test-main-site@test.com')
+ const org = (await ax.post('/api/organizations', { name: 'test_main_site_org' })).data
+ const owner = { type: 'organization', id: org.id, name: org.name }
+ const anonymousAx = await axios()
+ await anonymousAx.post('/api/sites',
+ { _id: 'test_main_site', owner, host: mainHost, title: 'Portail de test', theme: { primaryColor: '#FF00FF' } },
+ { params: { key: config.secretKeys.sites } })
+ const adminAx = (await createUser('admin@test.com', true)).ax
+ await adminAx.patch('/api/sites/test_main_site', {
+ mails: { from: 'portal@test.com', contact: 'hello@test.com' },
+ tosMessage: 'CGU du portail',
+ reducedPersonalInfoAtCreation: true
+ })
+ await testEnvAx.post('/clear-site-cache')
+ return { adminAx, owner }
+}
+
+test.describe('main site document', () => {
+ test.beforeEach(async () => {
+ await testEnvAx.delete('/')
+ await setCategories([])
+ })
+
+ test.afterEach(async () => {
+ await setCategories([])
+ })
+
+ test('is ignored when no category is enabled', async () => {
+ await seedMainSiteDoc()
+ const anonymousAx = await axios()
+ const publicSite = (await anonymousAx.get('/api/sites/_public')).data
+ assert.equal(publicSite.main, true)
+ assert.equal(publicSite.title, undefined)
+ assert.notEqual(publicSite.theme.colors.primary, '#FF00FF')
+ })
+
+ test('drives theme and title when those categories are enabled', async () => {
+ await seedMainSiteDoc()
+ await setCategories(['theme', 'title'])
+ const anonymousAx = await axios()
+ const publicSite = (await anonymousAx.get('/api/sites/_public')).data
+ assert.equal(publicSite.title, 'Portail de test')
+ assert.equal(publicSite.theme.colors.primary, '#FF00FF')
+ assert.equal(publicSite.authMode, 'onlyLocal')
+ assert.equal(publicSite.authProviders, undefined)
+ })
+
+ test('drives registration copy when that category is enabled', async () => {
+ await seedMainSiteDoc()
+ await setCategories(['registration'])
+ const anonymousAx = await axios()
+ const publicSite = (await anonymousAx.get('/api/sites/_public')).data
+ assert.equal(publicSite.tosMessage, 'CGU du portail')
+ assert.equal(publicSite.reducedPersonalInfoAtCreation, true)
+ })
+
+ test('the theme css hash matches the css served under it', async () => {
+ await seedMainSiteDoc()
+ const anonymousAx = await axios()
+
+ // the css does not carry the raw primary colour, it carries the
+ // contrast-computed text colours derived from it, so compare the two
+ // states rather than searching for a literal
+ await setCategories([])
+ const envCss = (await anonymousAx.get('/api/sites/_theme.css')).data
+
+ await setCategories(['theme'])
+ const hashes = (await anonymousAx.get('/api/sites/_hashes')).data
+ const hashedCss = (await anonymousAx.get(`/api/sites/${hashes.themeCss}/_theme.css`)).data
+ const plainCss = (await anonymousAx.get('/api/sites/_theme.css')).data
+
+ assert.equal(hashedCss, plainCss)
+ assert.notEqual(plainCss, envCss, 'the document theme must change the css served on the main host')
+ })
+
+ test('the theme css hash matches the css served under it with no document', async () => {
+ const anonymousAx = await axios()
+ const hashes = (await anonymousAx.get('/api/sites/_hashes')).data
+ const hashedCss = (await anonymousAx.get(`/api/sites/${hashes.themeCss}/_theme.css`)).data
+ const plainCss = (await anonymousAx.get('/api/sites/_theme.css')).data
+ assert.equal(hashedCss, plainCss)
+ })
+
+ test('mails use the document sender only when the mails category is enabled', async () => {
+ await seedMainSiteDoc()
+
+ await setCategories([])
+ await deleteAllEmails()
+ await createUser('test-mail-env@test.com')
+ const envMail = await findEmailTo('test-mail-env@test.com')
+ assert.ok(envMail, 'no mail captured for the env case')
+ assert.equal(envMail.envelope.from.address, 'no-reply@test.com')
+
+ await setCategories(['mails'])
+ await deleteAllEmails()
+ await createUser('test-mail-db@test.com')
+ const dbMail = await findEmailTo('test-mail-db@test.com')
+ assert.ok(dbMail, 'no mail captured for the db case')
+ assert.equal(dbMail.envelope.from.address, 'portal@test.com')
+ })
+
+ test('reports fields that are never honoured on the main host', async () => {
+ const { adminAx } = await seedMainSiteDoc()
+ await adminAx.patch('/api/sites/test_main_site', { authMode: 'ssoBackOffice' })
+ await setCategories(['theme', 'title', 'mails', 'registration'])
+ const site = (await adminAx.get('/api/sites/test_main_site')).data
+ assert.ok(Array.isArray(site.mainSiteWarnings))
+ assert.ok(site.mainSiteWarnings.some((w: string) => w.includes('authMode')), site.mainSiteWarnings.join(' | '))
+ })
+
+ test('reports stored values whose category is disabled', async () => {
+ const { adminAx } = await seedMainSiteDoc()
+ await setCategories(['title'])
+ const site = (await adminAx.get('/api/sites/test_main_site')).data
+ const joined = site.mainSiteWarnings.join(' | ')
+ assert.ok(joined.includes('MAIN_SITE_FROM_DB'), joined)
+ assert.ok(!site.mainSiteWarnings.some((w: string) => w.includes('MAIN_SITE_FROM_DB') && w.includes('title')), joined)
+ })
+
+ test('an ordinary site has no main-site warnings', async () => {
+ const serverConfig = await getServerConfig()
+ const { ax } = await createUser('test-other-site@test.com')
+ const org = (await ax.post('/api/organizations', { name: 'test_other_org' })).data
+ const anonymousAx = await axios()
+ await anonymousAx.post('/api/sites',
+ { _id: 'test_other_site', owner: { type: 'organization', id: org.id, name: org.name }, host: '127.0.0.1:' + process.env.NGINX_PORT2 },
+ { params: { key: serverConfig.secretKeys.sites } })
+ const adminAx = (await createUser('admin@test.com', true)).ax
+ const site = (await adminAx.get('/api/sites/test_other_site')).data
+ assert.deepEqual(site.mainSiteWarnings, [])
+ })
+
+ test('a full-document round-trip patch succeeds and does not toggle other sites', async () => {
+ const serverConfig = await getServerConfig()
+ const { adminAx, owner } = await seedMainSiteDoc()
+ const anonymousAx = await axios()
+ await anonymousAx.post('/api/sites',
+ { _id: 'test_sibling_site', owner, host: '127.0.0.1:' + process.env.NGINX_PORT2 },
+ { params: { key: serverConfig.secretKeys.sites } })
+ await adminAx.patch('/api/sites/test_sibling_site', { authMode: 'onlyLocal' })
+
+ // exactly what ui/src/pages/admin/sites/[id].vue sends: the fetched
+ // document minus _id / colorWarnings / owner / host / path
+ const fetched = (await adminAx.get('/api/sites/test_main_site')).data
+ const roundTrip = { ...fetched, isAccountMain: true }
+ delete roundTrip._id
+ delete roundTrip.colorWarnings
+ delete roundTrip.mainSiteWarnings
+ delete roundTrip.owner
+ delete roundTrip.host
+ delete roundTrip.path
+ delete roundTrip.updatedAt
+ // the theme is in assisted mode, so fillTheme recomputes colors from
+ // assistedModeColors on save — editing colors.primary directly would be
+ // overwritten
+ assert.equal(roundTrip.theme.assistedMode, true)
+ roundTrip.theme.assistedModeColors.primary = '#00FF00'
+
+ await adminAx.patch('/api/sites/test_main_site', roundTrip)
+
+ const sibling = (await adminAx.get('/api/sites/test_sibling_site')).data
+ assert.equal(sibling.authMode, 'onlyLocal', 'toggleMainSite must not have rewritten the sibling site')
+ const patched = (await adminAx.get('/api/sites/test_main_site')).data
+ assert.equal(patched.theme.colors.primary, '#00FF00')
+
+ // the patch schema is additionalProperties:false, so the computed
+ // mainSiteWarnings must be stripped from the body like colorWarnings is —
+ // otherwise every save of the main document 400s
+ await assert.rejects(
+ adminAx.patch('/api/sites/test_main_site', { ...roundTrip, mainSiteWarnings: ['x'] }),
+ { status: 400 }
+ )
+ })
+
+ test('a session on the main host is still a back-office session', async () => {
+ await seedMainSiteDoc()
+ await setCategories(['theme', 'title', 'mails', 'registration'])
+ // adminMode requires reqSite() === undefined on the main host; the
+ // document must not have changed that
+ const adminAx = await axiosAuth({ email: 'admin@test.com', adminMode: true })
+ const me = (await adminAx.get('/api/auth/me')).data
+ assert.ok(me.adminMode)
+ assert.equal(me.host, undefined)
+ })
+})
diff --git a/tests/features/main-site.e2e.spec.ts b/tests/features/main-site.e2e.spec.ts
new file mode 100644
index 00000000..a6a2a49a
--- /dev/null
+++ b/tests/features/main-site.e2e.spec.ts
@@ -0,0 +1,72 @@
+import { test, expect } from '../support/e2e-fixtures.ts'
+import { axios, axiosAuth, getServerConfig, testEnvAx } from '../support/axios.ts'
+
+test.describe('main site document admin page', () => {
+ let config: any
+
+ test.beforeEach(async () => {
+ await testEnvAx.post('/seed')
+ config = await getServerConfig()
+
+ const adminAx = await axiosAuth({ email: '_superadmin@test.com', password: 'Test1234', adminMode: true })
+ const org = (await adminAx.post('/api/organizations', { name: 'test_main-site-org' })).data
+ const anonymAx = await axios()
+
+ // seeded through the sites secret, exactly as portals-manager does
+ await anonymAx.post('/api/sites', {
+ _id: 'test_main_site',
+ owner: { type: 'organization', id: org.id, name: org.name },
+ host: new URL(config.publicUrl).host,
+ title: 'Portail de test',
+ theme: { primaryColor: '#FF00FF' }
+ }, { params: { key: config.secretKeys.sites } })
+
+ // tosMessage is not in the POST body schema, only a superadmin can set it,
+ // and with the registration category disabled it produces a warning
+ await adminAx.patch('/api/sites/test_main_site', { tosMessage: 'CGU du portail' })
+
+ await testEnvAx.patch('/config', { mainSiteFromDb: ['theme', 'title'] })
+ await testEnvAx.post('/clear-site-cache')
+ })
+
+ test.afterEach(async () => {
+ await testEnvAx.patch('/config', { mainSiteFromDb: [] })
+ await testEnvAx.post('/clear-site-cache')
+ })
+
+ test('explains the split, keeps auth sections, hides isAccountMain', async ({ page, appUrl, loginExisting }) => {
+ await loginExisting('_superadmin@test.com', { password: 'Test1234', adminMode: true })
+ await page.goto(appUrl('/admin/sites/test_main_site'))
+
+ await expect(page.getByTestId('main-site-banner')).toBeVisible({ timeout: 15_000 })
+ // the registration category is disabled but tosMessage is stored
+ await expect(page.getByTestId('main-site-warnings')).toContainText('MAIN_SITE_FROM_DB')
+
+ // auth sections stay visible: the form round-trips them, so hiding would
+ // conceal the values the warnings refer to. The generous timeout covers a
+ // cold vite compile of this route on the first navigation.
+ await expect(page.getByText('Gestion des utilisateurs')).toBeVisible({ timeout: 15_000 })
+
+ // isAccountMain is the one control not offered on the main document.
+ // Matched by label, not by text: the deprecated authMode field's own label
+ // quotes "Site principal du compte" and would match a text locator.
+ await expect(page.getByLabel('Site principal du compte', { exact: true })).toHaveCount(0)
+ // the sibling field of that section is still there, so the section itself
+ // did render and the assertion above is meaningful
+ await expect(page.getByLabel('Titre du site', { exact: true })).toBeVisible()
+
+ // and the form still saves
+ await expect(page.getByRole('textbox', { name: 'Couleur principale', exact: true })).toHaveValue('#FF00FF')
+ await page.getByRole('button', { name: /enregistrer|save/i }).click()
+ await page.waitForTimeout(1000)
+ const adminAx = await axiosAuth({ email: '_superadmin@test.com', password: 'Test1234', adminMode: true })
+ const after = (await adminAx.get('/api/sites/test_main_site')).data
+ expect(after.title).toBe('Portail de test')
+ })
+
+ test('marks the main site in the sites list', async ({ page, appUrl, loginExisting }) => {
+ await loginExisting('_superadmin@test.com', { password: 'Test1234', adminMode: true })
+ await page.goto(appUrl('/admin/sites'))
+ await expect(page.getByText('Site principal', { exact: true })).toBeVisible({ timeout: 15_000 })
+ })
+})
diff --git a/tests/features/main-site.unit.spec.ts b/tests/features/main-site.unit.spec.ts
new file mode 100644
index 00000000..a8093ac0
--- /dev/null
+++ b/tests/features/main-site.unit.spec.ts
@@ -0,0 +1,154 @@
+// The main site document is the site whose host+path matches publicUrl.
+// getEffectiveMainSite merges its presentation fields over config.* according
+// to config.mainSiteFromDb, and nothing else. The result is an ordinary
+// EffectiveSite that runs through the same renderers as any other site.
+
+import { strict as assert } from 'node:assert'
+import { test } from '@playwright/test'
+import { initMongo } from '../support/unit.ts'
+
+test.describe('main site document resolver', () => {
+ // the mongo client from initMongo is shared by every unit spec in this
+ // worker process — do not close it here, the next spec's initMongo() would
+ // try to reconnect an already-closed client and take the rest of the
+ // project down with it
+ test.beforeAll(async () => { await initMongo() })
+
+ test.beforeEach(async () => {
+ const mongo = (await import('../../api/src/mongo.ts')).default
+ await mongo.sites.deleteMany({ _id: { $regex: /^test_/ } })
+ const { getMainSiteDoc } = await import('../../api/src/sites/service.ts')
+ const { clearSiteResourceCaches } = await import('../../api/src/sites/main-site.ts')
+ getMainSiteDoc.clear()
+ clearSiteResourceCaches()
+ })
+
+ const seedMainSiteDoc = async (doc: any = {}) => {
+ const config = (await import('../../api/src/config.ts')).default
+ const mongo = (await import('../../api/src/mongo.ts')).default
+ const publicUrl = new URL(config.publicUrl)
+ await mongo.sites.insertOne({
+ _id: 'test_main_site',
+ owner: { type: 'organization', id: 'test_org' },
+ host: publicUrl.host,
+ updatedAt: new Date().toISOString(),
+ theme: { ...config.theme, colors: { ...config.theme.colors, primary: '#FF00FF' } },
+ title: 'Portail de test',
+ mails: { from: 'portal@test.com', contact: 'hello@test.com' },
+ tosMessage: 'CGU du portail',
+ reducedPersonalInfoAtCreation: true,
+ authMode: 'onlyLocal',
+ ...doc
+ } as any)
+ const { getMainSiteDoc } = await import('../../api/src/sites/service.ts')
+ getMainSiteDoc.clear()
+ }
+
+ const withCategories = async (categories: string[]) => {
+ const config = (await import('../../api/src/config.ts')).default
+ Object.defineProperty(config, 'mainSiteFromDb', { value: categories, writable: true, configurable: true })
+ const { clearSiteResourceCaches } = await import('../../api/src/sites/main-site.ts')
+ clearSiteResourceCaches()
+ }
+
+ test('finds the document sitting on the publicUrl host', async () => {
+ await seedMainSiteDoc()
+ const { getMainSiteDoc } = await import('../../api/src/sites/service.ts')
+ assert.equal((await getMainSiteDoc())?._id, 'test_main_site')
+ })
+
+ test('ignores a document on another host', async () => {
+ await seedMainSiteDoc({ host: 'somewhere-else.test' })
+ const { getMainSiteDoc } = await import('../../api/src/sites/service.ts')
+ assert.equal(await getMainSiteDoc(), undefined)
+ })
+
+ test('with an empty category list everything comes from env', async () => {
+ await seedMainSiteDoc()
+ await withCategories([])
+ const config = (await import('../../api/src/config.ts')).default
+ const { getEffectiveMainSite } = await import('../../api/src/sites/main-site.ts')
+ const presentation = await getEffectiveMainSite()
+ assert.equal(presentation.theme.colors.primary, config.theme.colors.primary)
+ assert.notEqual(presentation.theme.colors.primary, '#FF00FF')
+ assert.equal(presentation.title, undefined)
+ assert.equal(presentation.mails?.from, config.mails.from)
+ assert.equal(presentation.tosMessage, undefined)
+ // no category contributed, so the id stays the env-only one
+ assert.equal(presentation._id, '_main')
+ })
+
+ test('each category is honoured independently', async () => {
+ await seedMainSiteDoc()
+ const config = (await import('../../api/src/config.ts')).default
+ const { getEffectiveMainSite } = await import('../../api/src/sites/main-site.ts')
+
+ await withCategories(['theme'])
+ let presentation = await getEffectiveMainSite()
+ assert.equal(presentation.theme.colors.primary, '#FF00FF')
+ assert.equal(presentation.title, undefined)
+ assert.equal(presentation.mails?.from, config.mails.from)
+
+ await withCategories(['title', 'mails', 'registration'])
+ presentation = await getEffectiveMainSite()
+ assert.equal(presentation.theme.colors.primary, config.theme.colors.primary)
+ assert.equal(presentation.title, 'Portail de test')
+ assert.equal(presentation.mails?.from, 'portal@test.com')
+ assert.equal(presentation.mails?.contact, 'hello@test.com')
+ assert.equal(presentation.tosMessage, 'CGU du portail')
+ assert.equal(presentation.reducedPersonalInfoAtCreation, true)
+ })
+
+ test('never exposes trust-bearing fields', async () => {
+ await seedMainSiteDoc({ authProviders: [{ type: 'saml2', title: 'evil' }], applications: [{ id: 'x' }] })
+ await withCategories(['theme', 'title', 'mails', 'registration'])
+ const { getEffectiveMainSite } = await import('../../api/src/sites/main-site.ts')
+ const { getPublicSiteInfo } = await import('../../api/src/utils/public-site-info.ts')
+ const publicInfo = getPublicSiteInfo(await getEffectiveMainSite())
+ assert.equal((publicInfo as any).authProviders, undefined)
+ assert.equal((publicInfo as any).applications, undefined)
+ assert.equal((publicInfo as any).owner, undefined)
+ assert.equal(publicInfo.authMode, 'onlyLocal')
+ assert.equal(publicInfo.main, true)
+ assert.equal(publicInfo.isAccountMain, true)
+ })
+
+ test('the theme css hash describes the css actually produced', async () => {
+ await seedMainSiteDoc()
+ await withCategories(['theme'])
+ const crypto = await import('node:crypto')
+ const { getEffectiveMainSite } = await import('../../api/src/sites/main-site.ts')
+ const { getThemeCss, getThemeCssHash } = await import('../../api/src/utils/theme.ts')
+ const site = await getEffectiveMainSite()
+ const themeCss = getThemeCss(site.theme, site.path ?? '')
+ assert.equal(crypto.createHash('md5').update(themeCss).digest('hex'), getThemeCssHash(site))
+ })
+
+ // Regression: getSiteExtraParams used to call getSiteByUrl directly, with no
+ // publicUrl exclusion, while the /api/sites/_* endpoints call reqSite. On a
+ // host that is both the publicUrl host and carries a document, the served
+ // HTML asked for /api/sites//_theme.css and got the *env* CSS
+ // back under max-age=31536000, immutable.
+ test('the hashes injected into the html match the resources actually served', async () => {
+ await seedMainSiteDoc()
+ const config = (await import('../../api/src/config.ts')).default
+ const { getSiteExtraParams } = await import('../../api/src/sites/spa-params.ts')
+ const { getEffectiveMainSite } = await import('../../api/src/sites/main-site.ts')
+ const { getThemeCssHash } = await import('../../api/src/utils/theme.ts')
+ const { getPublicSiteInfoHash } = await import('../../api/src/utils/public-site-info.ts')
+ const siteUrl = config.publicUrl.replace(/\/simple-directory$/, '')
+
+ for (const categories of [[], ['theme'], ['theme', 'title']]) {
+ await withCategories(categories)
+ const params = await getSiteExtraParams(siteUrl)
+ const site = await getEffectiveMainSite()
+ assert.equal(params.THEME_CSS_HASH, getThemeCssHash(site), `categories=${categories.join(',')}`)
+ assert.equal(params.PUBLIC_SITE_INFO_HASH, getPublicSiteInfoHash(site), `categories=${categories.join(',')}`)
+ }
+
+ await withCategories(['title'])
+ assert.equal((await getSiteExtraParams(siteUrl)).SITE_TITLE, 'Portail de test')
+ await withCategories([])
+ assert.equal((await getSiteExtraParams(siteUrl)).SITE_TITLE, 'Simple Directory')
+ })
+})
diff --git a/tests/support/unit.ts b/tests/support/unit.ts
index 7c20b53a..edb23dca 100644
--- a/tests/support/unit.ts
+++ b/tests/support/unit.ts
@@ -13,6 +13,11 @@ export const initMongo = async () => {
initialized = true
}
+// Closes the client that initMongo shares across every unit spec in the worker
+// process. Do NOT call this from a spec's afterAll: the next spec's initMongo()
+// reconnects the same closed client and throws MongoNotConnectedError, taking
+// the rest of the unit project down with it. Specs just call initMongo() and
+// let the process exit clean up.
export const closeMongo = async () => {
if (!initialized) return
const mongo = (await import('../../api/src/mongo.ts')).default
diff --git a/ui/src/composables/use-store.ts b/ui/src/composables/use-store.ts
index 28737d40..ffaba4f5 100644
--- a/ui/src/composables/use-store.ts
+++ b/ui/src/composables/use-store.ts
@@ -29,8 +29,16 @@ function createStore () {
const mainPublicUrl = new URL($uiConfig.publicUrl)
const isAccountMainSite = host === mainPublicUrl.host
+ // a site document is *the main site document* when its host+path matches
+ // publicUrl. Its presentation drives the main site for the categories in
+ // MAIN_SITE_FROM_DB; its auth configuration never applies.
+ // See docs/architecture/main-site-config.md
+ const isMainSiteDoc = (site: { host: string, path?: string }) =>
+ $uiConfig.publicUrl.startsWith(`${mainPublicUrl.protocol}//${site.host}${site.path ?? ''}`)
+
return {
sitePublic,
+ isMainSiteDoc,
userDetailsFetch,
authProvidersFetch,
patchOrganization,
diff --git a/ui/src/pages/admin/sites/[id].vue b/ui/src/pages/admin/sites/[id].vue
index 7ca9f7f4..f47bea01 100644
--- a/ui/src/pages/admin/sites/[id].vue
+++ b/ui/src/pages/admin/sites/[id].vue
@@ -20,6 +20,27 @@
:href="siteHref"
class="simple-link"
>{{ siteHref }} - {{ site.data.value?._id }}
+
+ {{ $t('pages.admin.site.mainSiteExplanation') }}
+
+
{
density: 'comfortable',
initialValidation: 'always',
context: {
+ isMainSite: isMainSite.value,
hasAccountMainSite: otherSites?.some(s => s.isAccountMain),
otherSites: otherSites?.map(site => site.host),
otherSitesProviders: otherSites?.reduce((a, site) => { a[site.host] = (site.authProviders || []).filter(p => p.type === 'oidc').map(p => `${p.type}:${p.id}`); return a }, {} as Record)
@@ -81,7 +103,7 @@ const vjsfOptions = computed(() => {
}
})
-type SiteWithColorWarnings = Site & { colorWarnings: string[] }
+type SiteWithColorWarnings = Site & { colorWarnings: string[], mainSiteWarnings?: string[] }
const siteId = useRoute<'/admin/sites/[id]'>().params.id
const sites = useFetch<{ count: number, results: SiteWithColorWarnings[] }>($apiPath + '/sites', { query: { showAll: true } })
@@ -89,13 +111,15 @@ const site = useFetch($apiPath + '/sites/' + siteId, { qu
const siteHref = computed(() => `${site.data.value?.host.startsWith('localhost:') ? 'http' : 'https'}://${site.data.value?.host}${site.data.value?.path ?? ''}`)
-const { patchSite } = useStore()
+const { patchSite, isMainSiteDoc } = useStore()
+const isMainSite = computed(() => !!site.data.value && isMainSiteDoc(site.data.value))
watch(site.data, () => {
if (!site.data.value) return
const siteClone = JSON.parse(JSON.stringify(site.data.value))
delete siteClone._id
delete siteClone.colorWarnings
+ delete siteClone.mainSiteWarnings
delete siteClone.owner
delete siteClone.host
delete siteClone.path
diff --git a/ui/src/pages/admin/sites/index.vue b/ui/src/pages/admin/sites/index.vue
index f02fc46a..ad3857ff 100644
--- a/ui/src/pages/admin/sites/index.vue
+++ b/ui/src/pages/admin/sites/index.vue
@@ -39,6 +39,14 @@
target="blank"
class="text-primary"
>{{ `${props.item.host}${props.item.path ?? ''}` }}
+
+ {{ $t('pages.admin.sites.mainSite') }}
+
{{ props.item._id }} |
@@ -114,7 +122,7 @@
:icon="mdiLoginVariant"
@click="siteRedirect(props.item)"
/>
-
+
@@ -146,11 +154,14 @@
|