From 59bef590c33a5d80296a0347d34a76019506d21f Mon Sep 17 00:00:00 2001 From: Alban Mouton Date: Fri, 11 Sep 2026 14:07:02 +0200 Subject: [PATCH 01/14] feat(sites): add MAIN_SITE_FROM_DB category list config Declares which categories of main-site configuration are read from the site document on the publicUrl host rather than from env vars. Defaults to an empty list, so no existing install changes behaviour. The four category names are constrained by an enum so a typo refuses to start. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017nA8Pb1y3ytneydAwDTU23 --- api/config/custom-environment-variables.cjs | 1 + api/config/default.cjs | 8 ++++++ api/config/type/schema.json | 10 ++++++- api/src/ui-config.ts | 1 + tests/features/main-site-config.unit.spec.ts | 30 ++++++++++++++++++++ 5 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 tests/features/main-site-config.unit.spec.ts diff --git a/api/config/custom-environment-variables.cjs b/api/config/custom-environment-variables.cjs index 98e249d8..24259c03 100644 --- a/api/config/custom-environment-variables.cjs +++ b/api/config/custom-environment-variables.cjs @@ -324,6 +324,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 c3bbd1e8..5db97310 100644 --- a/api/config/default.cjs +++ b/api/config/default.cjs @@ -274,6 +274,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/src/ui-config.ts b/api/src/ui-config.ts index 1cdbae5a..443f59c6 100644 --- a/api/src/ui-config.ts +++ b/api/src/ui-config.ts @@ -8,6 +8,7 @@ export const uiConfig = { publicUrl: config.publicUrl, theme: config.theme, manageSites: config.manageSites, + mainSiteFromDb: config.mainSiteFromDb, i18n: config.i18n, tosUrl: config.tosUrl, passwordless: config.passwordless, 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 + }) +}) From 21ec8783e35ba4da7dbdc8c1415fb8c009ef9e92 Mon Sep 17 00:00:00 2001 From: Alban Mouton Date: Fri, 11 Sep 2026 14:12:34 +0200 Subject: [PATCH 02/14] feat(sites): resolve main site document presentation over env config A site document whose host+path matches publicUrl is the "main site document". getMainSitePresentation merges its presentation fields over config.* according to mainSiteFromDb; reqSite() is untouched and still returns undefined on that host, so identity and trust rules are unchanged by construction. The public-info builder moves next to the env baseline constants so the two cannot drift, and the constants are kept because ui/vite.config.ts imports them for the dev server and must not reach into mongo. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017nA8Pb1y3ytneydAwDTU23 --- api/src/services.ts | 2 +- api/src/sites/main-site.ts | 113 ++++++++++++++++++++++++ api/src/sites/service.ts | 18 ++++ api/src/test-env.ts | 8 +- api/src/utils/public-site-info.ts | 42 +++++++-- tests/features/main-site.unit.spec.ts | 118 ++++++++++++++++++++++++++ 6 files changed, 291 insertions(+), 10 deletions(-) create mode 100644 api/src/sites/main-site.ts create mode 100644 tests/features/main-site.unit.spec.ts diff --git a/api/src/services.ts b/api/src/services.ts index e0ccffd7..3ab0e2d9 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, isMainSiteUrl, isMainSiteDoc, getMainSiteDoc } 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..1b220379 --- /dev/null +++ b/api/src/sites/main-site.ts @@ -0,0 +1,113 @@ +import crypto from 'node:crypto' +import serialize from 'serialize-javascript' +import config from '#config' +import { type Site, type SitePublic } from '#types' +import { getMainSiteDoc } from './service.ts' +import { + type MainSitePresentation, + envMainSitePresentation, + buildMainPublicSiteInfo, + defaultPublicSiteInfo, + defaultPublicSiteInfoHash +} from '../utils/public-site-info.ts' +import { getThemeCss, defaultThemeCss, defaultThemeCssHash } from '../utils/theme.ts' + +export type { MainSitePresentation } + +export type MainSiteCategory = 'theme' | 'title' | 'mails' | 'registration' + +export 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). +export const mainSiteIgnoredFields = ['authMode', 'authOnlyOtherSite', 'authProviders', 'applications', 'isAccountMain'] as const + +export 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) + +export const getMainSitePresentation = async (): Promise => { + const doc = await getMainSiteDoc() + const used: MainSiteCategory[] = [] + const presentation = envMainSitePresentation() + if (doc) { + if (enabled('theme') && doc.theme) { + presentation.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 (!presentation.theme.logo && !config.theme.logo) { + presentation.theme = { ...presentation.theme, logo: `/simple-directory/api/avatars/${doc.owner.type}/${doc.owner.id}/avatar.png` } + } + used.push('theme') + } + if (enabled('title') && doc.title) { + presentation.title = doc.title + used.push('title') + } + if (enabled('mails') && doc.mails) { + presentation.mails = { + from: doc.mails.from ?? presentation.mails.from, + contact: doc.mails.contact ?? presentation.mails.contact + } + used.push('mails') + } + if (enabled('registration') && (doc.tosMessage !== undefined || doc.reducedPersonalInfoAtCreation !== undefined)) { + presentation.tosMessage = doc.tosMessage + presentation.reducedPersonalInfoAtCreation = doc.reducedPersonalInfoAtCreation + used.push('registration') + } + if (used.length) presentation.docKey = `${doc._id}-${doc.updatedAt}-${used.join(',')}` + } + return presentation +} + +type MainSiteResources = { + publicInfo: SitePublic & { main: true }, + publicInfoHash: string, + themeCss: string, + themeCssHash: string +} + +// the env baseline, shared with ui/vite.config.ts so dev and prod inject the +// same hashes when no document contributes +const envResources: MainSiteResources = { + publicInfo: defaultPublicSiteInfo, + publicInfoHash: defaultPublicSiteInfoHash, + themeCss: defaultThemeCss, + themeCssHash: defaultThemeCssHash +} + +const resourcesCache: Record = {} + +// Hashes are computed from the content actually served, so _hashes and +// /:hash/_theme.css cannot diverge (they used to: app.ts read the document +// while the endpoints read env, and the mismatched hash was cached immutable +// for a year). +export const getMainSiteResources = async (): Promise => { + const presentation = await getMainSitePresentation() + if (!presentation.docKey) return envResources + if (!resourcesCache[presentation.docKey]) { + const publicInfo = buildMainPublicSiteInfo(presentation) + const themeCss = getThemeCss(presentation.theme) + resourcesCache[presentation.docKey] = { + publicInfo, + publicInfoHash: crypto.createHash('md5').update(serialize(publicInfo)).digest('hex'), + themeCss, + themeCssHash: crypto.createHash('md5').update(themeCss).digest('hex') + } + } + return resourcesCache[presentation.docKey] +} + +export const clearMainSiteCache = () => { + for (const key of Object.keys(resourcesCache)) delete resourcesCache[key] +} diff --git a/api/src/sites/service.ts b/api/src/sites/service.ts index 25b1502d..7f187d75 100644 --- a/api/src/sites/service.ts +++ b/api/src/sites/service.ts @@ -26,6 +26,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/test-env.ts b/api/src/test-env.ts index 627a0128..18442c17 100644 --- a/api/src/test-env.ts +++ b/api/src/test-env.ts @@ -148,10 +148,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 { clearMainSiteCache } = await import('./sites/main-site.ts') getSiteByHost.clear() + getMainSiteDoc.clear() + clearMainSiteCache() 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..433b7b0a 100644 --- a/api/src/utils/public-site-info.ts +++ b/api/src/utils/public-site-info.ts @@ -12,7 +12,7 @@ const removeUndef = (obj?: Record) => { } } -const lighterTheme = (fullTheme: Theme) => { +export const lighterTheme = (fullTheme: Theme) => { const theme = clone(fullTheme) if (!theme.dark) delete theme.darkColors if (!theme.hc) delete theme.hcColors @@ -54,11 +54,39 @@ export const getPublicSiteInfoHash = (site: Site) => { return publicSiteInfoHashCache[cacheKey] } -export const defaultPublicSiteInfo = { - main: true, - host: publicHost, - theme: lighterTheme(config.theme), - isAccountMain: true, - authMode: 'onlyLocal', +export type MainSitePresentation = { + theme: Theme, + title?: string, + tosMessage?: string, + reducedPersonalInfoAtCreation?: boolean, + mails: { from?: string, contact?: string }, + // identity of the document actually contributing, used as a cache key; + // undefined when every value comes from the environment + docKey?: string } + +export const envMainSitePresentation = (): MainSitePresentation => ({ + theme: config.theme, + mails: { from: config.mails.from, contact: config.contact } +}) + +// The main site is always a locally authenticated back-office, whatever its +// document says: authMode, authProviders, owner and isAccountMain are never +// taken from it. See docs/architecture/main-site-config.md +export const buildMainPublicSiteInfo = (presentation: MainSitePresentation): SitePublic & { main: true } => { + const info: Record = { + main: true, + host: publicHost, + theme: lighterTheme(presentation.theme), + title: presentation.title, + tosMessage: presentation.tosMessage, + reducedPersonalInfoAtCreation: presentation.reducedPersonalInfoAtCreation, + isAccountMain: true, + authMode: 'onlyLocal' + } + removeUndef(info) + return info as SitePublic & { main: true } +} + +export const defaultPublicSiteInfo = buildMainPublicSiteInfo(envMainSitePresentation()) export const defaultPublicSiteInfoHash = crypto.createHash('md5').update(serialize(defaultPublicSiteInfo)).digest('hex') diff --git a/tests/features/main-site.unit.spec.ts b/tests/features/main-site.unit.spec.ts new file mode 100644 index 00000000..50a7575d --- /dev/null +++ b/tests/features/main-site.unit.spec.ts @@ -0,0 +1,118 @@ +// The main site document is the site whose host+path matches publicUrl. +// getMainSitePresentation merges its presentation fields over config.* +// according to config.mainSiteFromDb, and nothing else. + +import { strict as assert } from 'node:assert' +import { test } from '@playwright/test' +import { initMongo, closeMongo } from '../support/unit.ts' + +test.describe('main site document resolver', () => { + test.beforeAll(async () => { await initMongo() }) + test.afterAll(async () => { await closeMongo() }) + + 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 { clearMainSiteCache } = await import('../../api/src/sites/main-site.ts') + getMainSiteDoc.clear() + clearMainSiteCache() + }) + + 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 { clearMainSiteCache } = await import('../../api/src/sites/main-site.ts') + clearMainSiteCache() + } + + 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 { getMainSitePresentation } = await import('../../api/src/sites/main-site.ts') + const presentation = await getMainSitePresentation() + 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) + assert.equal(presentation.docKey, undefined) + }) + + test('each category is honoured independently', async () => { + await seedMainSiteDoc() + const config = (await import('../../api/src/config.ts')).default + const { getMainSitePresentation } = await import('../../api/src/sites/main-site.ts') + + await withCategories(['theme']) + let presentation = await getMainSitePresentation() + 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 getMainSitePresentation() + 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 { getMainSiteResources } = await import('../../api/src/sites/main-site.ts') + const { publicInfo } = await getMainSiteResources() + 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 { getMainSiteResources } = await import('../../api/src/sites/main-site.ts') + const { themeCss, themeCssHash } = await getMainSiteResources() + assert.equal(crypto.createHash('md5').update(themeCss).digest('hex'), themeCssHash) + }) +}) From 87ef6c64ae47e60c06d400c61762c9c418fd54bd Mon Sep 17 00:00:00 2001 From: Alban Mouton Date: Fri, 11 Sep 2026 14:16:32 +0200 Subject: [PATCH 03/14] feat(sites): serve main site presentation from its document The /api/sites/_* presentation endpoints take their values from the main site resolver instead of the env-only constants. The constants stay exported: app.ts and ui/vite.config.ts still import them, and the resolver returns them as its env baseline. Also fixes reducedPersonalInfoAtCreation missing from getPublicSiteInfo, which made the field unreachable from the login page on every site even though site-public declares it and login.vue reads it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017nA8Pb1y3ytneydAwDTU23 --- api/src/sites/router.ts | 27 ++++--- api/src/utils/public-site-info.ts | 1 + tests/features/main-site.api.spec.ts | 110 +++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 tests/features/main-site.api.spec.ts diff --git a/api/src/sites/router.ts b/api/src/sites/router.ts index 848470aa..a0bfeb7c 100644 --- a/api/src/sites/router.ts +++ b/api/src/sites/router.ts @@ -4,7 +4,7 @@ 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 { getThemeCss, getThemeCssHash } from '../utils/theme.ts' import { isOIDCProvider, reqSite } from '#services' import { reqI18n } from '#i18n' import { getOidcProviderId } from '../oauth/oidc.ts' @@ -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 { getMainSiteResources, getMainSitePresentation } from './main-site.ts' import serialize from 'serialize-javascript' const debugPostSite = Debug('post-site') @@ -212,7 +213,7 @@ router.get('/_public', async (req, res, next) => { // 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 publicSiteInfo = site ? await getPublicSiteInfo(site) : (await getMainSiteResources()).publicInfo res.send(publicSiteInfo) }) router.get('/_public.js', async (req, res, next) => { @@ -220,7 +221,7 @@ router.get('/_public.js', async (req, res, next) => { // 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 publicSiteInfo = site ? await getPublicSiteInfo(site) : (await getMainSiteResources()).publicInfo res.contentType('application/javascript') res.send(`window.__PUBLIC_SITE_INFO=${serialize(publicSiteInfo)}`) }) @@ -229,14 +230,14 @@ router.get('/:hash/_public.js', async (req, res, next) => { // 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 publicSiteInfo = site ? await getPublicSiteInfo(site) : (await getMainSiteResources()).publicInfo // 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 getMainSitePresentation()).theme) }) router.get('/_theme.css', async (req, res, next) => { @@ -244,7 +245,7 @@ router.get('/_theme.css', async (req, res, next) => { // 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 css = site ? getThemeCss(site.theme, site.path ?? '') : (await getMainSiteResources()).themeCss res.contentType('css') res.send(css) }) @@ -254,23 +255,25 @@ router.get('/:hash/_theme.css', async (req, res, next) => { res.setHeader('X-Accel-Buffering', 'yes') const site = await reqSite(req) // TODO: fail if hash doesn't match ? - const css = site ? getThemeCss(site.theme, site.path ?? '') : defaultThemeCss + const css = site ? getThemeCss(site.theme, site.path ?? '') : (await getMainSiteResources()).themeCss res.contentType('css') res.send(css) }) router.get('/_hashes', async (req, res, next) => { const site = await reqSite(req) + const mainResources = site ? undefined : await getMainSiteResources() res.send({ - publicInfo: site ? getPublicSiteInfoHash(site) : defaultPublicSiteInfoHash, - themeCss: site ? getThemeCssHash(site) : defaultThemeCssHash, - preloadLinks: site?.theme.preloadLinks ?? config.theme.preloadLinks ?? [] + publicInfo: site ? getPublicSiteInfoHash(site) : mainResources!.publicInfoHash, + themeCss: site ? getThemeCssHash(site) : mainResources!.themeCssHash, + preloadLinks: site?.theme.preloadLinks ?? (await getMainSitePresentation()).theme.preloadLinks ?? [] }) }) router.get('/:id/_theme_warnings', async (req, res, next) => { const site = await reqSite(req) const { localeCode } = reqI18n(req) - res.send(getSiteColorsWarnings(localeCode as 'fr' | 'en', site?.theme ?? config.theme, site?.authProviders as { title?: string, color?: string }[])) + const theme = site?.theme ?? (await getMainSitePresentation()).theme + res.send(getSiteColorsWarnings(localeCode as 'fr' | 'en', theme, site?.authProviders as { title?: string, color?: string }[])) }) router.get('/:id', async (req, res, next) => { diff --git a/api/src/utils/public-site-info.ts b/api/src/utils/public-site-info.ts index 433b7b0a..67107f5d 100644 --- a/api/src/utils/public-site-info.ts +++ b/api/src/utils/public-site-info.ts @@ -37,6 +37,7 @@ 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` diff --git a/tests/features/main-site.api.spec.ts b/tests/features/main-site.api.spec.ts new file mode 100644 index 00000000..05351773 --- /dev/null +++ b/tests/features/main-site.api.spec.ts @@ -0,0 +1,110 @@ +// 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 } from '../support/axios.ts' + +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('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) + }) +}) From c7e3a1612e4f611ded8ece5e734e7b6a0560c305 Mon Sep 17 00:00:00 2001 From: Alban Mouton Date: Fri, 11 Sep 2026 14:20:14 +0200 Subject: [PATCH 04/14] fix(sites): make the injected theme hash describe the css actually served MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getSiteExtraParams called 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 site document, the served HTML asked for /api/sites//_theme.css and got the env CSS back under 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 logic moves to sites/spa-params.ts where it can be tested directly — in dev, /login is served by vite rather than by this middleware, so the HTTP path cannot exercise it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017nA8Pb1y3ytneydAwDTU23 --- api/src/app.ts | 24 ++----------------- api/src/sites/spa-params.ts | 34 +++++++++++++++++++++++++++ tests/features/main-site.unit.spec.ts | 26 ++++++++++++++++++++ 3 files changed, 62 insertions(+), 22 deletions(-) create mode 100644 api/src/sites/spa-params.ts 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/sites/spa-params.ts b/api/src/sites/spa-params.ts new file mode 100644 index 00000000..024b601e --- /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 { getMainSiteResources } 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. +export const escapeHtml = (value: string) => value + .replace(/&/g, '&').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 resolved through + * getMainSiteResources, never read raw. 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) + const mainResources = site ? undefined : await getMainSiteResources() + return { + THEME_CSS_HASH: site ? getThemeCssHash(site) : mainResources!.themeCssHash, + PUBLIC_SITE_INFO_HASH: site ? getPublicSiteInfoHash(site) : mainResources!.publicInfoHash, + // 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 || mainResources?.publicInfo.title || 'Simple Directory') + } +} diff --git a/tests/features/main-site.unit.spec.ts b/tests/features/main-site.unit.spec.ts index 50a7575d..ff1615ab 100644 --- a/tests/features/main-site.unit.spec.ts +++ b/tests/features/main-site.unit.spec.ts @@ -115,4 +115,30 @@ test.describe('main site document resolver', () => { const { themeCss, themeCssHash } = await getMainSiteResources() assert.equal(crypto.createHash('md5').update(themeCss).digest('hex'), themeCssHash) }) + + // 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/<document-hash>/_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 { getMainSiteResources } = await import('../../api/src/sites/main-site.ts') + const siteUrl = config.publicUrl.replace(/\/simple-directory$/, '') + + for (const categories of [[], ['theme'], ['theme', 'title']]) { + await withCategories(categories) + const params = await getSiteExtraParams(siteUrl) + const resources = await getMainSiteResources() + assert.equal(params.THEME_CSS_HASH, resources.themeCssHash, `categories=${categories.join(',')}`) + assert.equal(params.PUBLIC_SITE_INFO_HASH, resources.publicInfoHash, `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') + }) }) From f51c3c9b05395065e4c2ba55f08000e01302a806 Mon Sep 17 00:00:00 2001 From: Alban Mouton <alban.mouton@gmail.com> Date: Fri, 11 Sep 2026 14:22:29 +0200 Subject: [PATCH 05/14] feat(mails): gate main-host mail theme and sender on the category list The mail path resolved its site with getSiteByHost, which has no publicUrl exclusion, so a document on the main host already drove the sender and contact address while every other consumer read env. It now goes through the main site resolver, and the theme and sender become independent: the document's theme used to apply only when mails.from was also set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nA8Pb1y3ytneydAwDTU23 --- api/src/mails/service.ts | 30 +++++++++++++++++++++------- tests/features/main-site.api.spec.ts | 26 +++++++++++++++++++++++- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/api/src/mails/service.ts b/api/src/mails/service.ts index a190f8dd..c11b52d8 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 { getMainSitePresentation } 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 presentation = await getMainSitePresentation() + Object.assign(flatTheme, flatten({ theme: presentation.theme })) + logo = presentation.theme.logo || logo + from = presentation.mails.from ?? from + contact = presentation.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/tests/features/main-site.api.spec.ts b/tests/features/main-site.api.spec.ts index 05351773..f878090b 100644 --- a/tests/features/main-site.api.spec.ts +++ b/tests/features/main-site.api.spec.ts @@ -4,7 +4,13 @@ import { strict as assert } from 'node:assert' import { test } from '@playwright/test' -import { axios, axiosAuth, testEnvAx, createUser, getServerConfig } from '../support/axios.ts' +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 }) @@ -97,6 +103,24 @@ test.describe('main site document', () => { 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('a session on the main host is still a back-office session', async () => { await seedMainSiteDoc() await setCategories(['theme', 'title', 'mails', 'registration']) From 04aaf71579422b04cf4dc715f668424b5d0a829d Mon Sep 17 00:00:00 2001 From: Alban Mouton <alban.mouton@gmail.com> Date: Fri, 11 Sep 2026 14:24:36 +0200 Subject: [PATCH 06/14] feat(sites): report ignored main-site fields via mainSiteWarnings GET /api/sites/:id and the showAll list gain mainSiteWarnings: one entry per never-honoured field the document carries, one per category stored but absent from MAIN_SITE_FROM_DB. A boot check logs the same report and raises an internalError when the document carries an inert field. Nothing is refused: the admin form round-trips the whole document, so a write barrier would reject an idempotent save, and the only non-UI writer (portals) cannot send those fields anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nA8Pb1y3ytneydAwDTU23 --- api/i18n/en.js | 6 ++++++ api/i18n/fr.js | 6 ++++++ api/src/server.ts | 13 ++++++++++++ api/src/sites/main-site.ts | 22 ++++++++++++++++++++ api/src/sites/router.ts | 9 ++++---- tests/features/main-site.api.spec.ts | 31 ++++++++++++++++++++++++++++ 6 files changed, 83 insertions(+), 4 deletions(-) diff --git a/api/i18n/en.js b/api/i18n/en.js index 6c1ba26c..c4af35a7 100644 --- a/api/i18n/en.js +++ b/api/i18n/en.js @@ -558,5 +558,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 09caaccf..5083a9c4 100644 --- a/api/i18n/fr.js +++ b/api/i18n/fr.js @@ -558,5 +558,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/server.ts b/api/src/server.ts index c969343c..a9b26f85 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -18,6 +18,9 @@ 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 { internalError } from '@data-fair/lib-node/observer.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 +59,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/sites/main-site.ts b/api/src/sites/main-site.ts index 1b220379..c36978cb 100644 --- a/api/src/sites/main-site.ts +++ b/api/src/sites/main-site.ts @@ -2,6 +2,7 @@ import crypto from 'node:crypto' import serialize from 'serialize-javascript' import config from '#config' import { type Site, type SitePublic } from '#types' +import { getMessage } from '#i18n' import { getMainSiteDoc } from './service.ts' import { type MainSitePresentation, @@ -111,3 +112,24 @@ export const getMainSiteResources = async (): Promise<MainSiteResources> => { export const clearMainSiteCache = () => { for (const key of Object.keys(resourcesCache)) delete resourcesCache[key] } + +// 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 a0bfeb7c..57b9805f 100644 --- a/api/src/sites/router.ts +++ b/api/src/sites/router.ts @@ -5,7 +5,7 @@ import { reqUser, reqUserAuthenticated, reqSiteUrl, httpError, reqSessionAuthent import { nanoid } from 'nanoid' import { findAllSites, findOwnerSites, patchSite, deleteSite, getSite, toggleMainSite, findMainSite } from './service.ts' import { getThemeCss, getThemeCssHash } from '../utils/theme.ts' -import { isOIDCProvider, reqSite } from '#services' +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' @@ -14,7 +14,7 @@ import Debug from 'debug' import { cipher } from '../utils/cipher.ts' import { type OpenIDConnect } from '#types/site/index.ts' import { getPublicSiteInfo, getPublicSiteInfoHash } from '../utils/public-site-info.ts' -import { getMainSiteResources, getMainSitePresentation } from './main-site.ts' +import { getMainSiteResources, getMainSitePresentation, getMainSiteWarnings } from './main-site.ts' import serialize from 'serialize-javascript' const debugPostSite = Debug('post-site') @@ -38,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) => { diff --git a/tests/features/main-site.api.spec.ts b/tests/features/main-site.api.spec.ts index f878090b..1d7d28b2 100644 --- a/tests/features/main-site.api.spec.ts +++ b/tests/features/main-site.api.spec.ts @@ -121,6 +121,37 @@ test.describe('main site document', () => { 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 session on the main host is still a back-office session', async () => { await seedMainSiteDoc() await setCategories(['theme', 'title', 'mails', 'registration']) From c9584c0df05e494e87656315a104e576cd9dd54b Mon Sep 17 00:00:00 2001 From: Alban Mouton <alban.mouton@gmail.com> Date: Fri, 11 Sep 2026 14:26:18 +0200 Subject: [PATCH 07/14] fix(sites): do not re-toggle the account main site on the main document PATCH runs toggleMainSite whenever isAccountMain is truthy, rewriting every other site of the owner to onlyOtherSite. The admin form sends the whole document back on each save, so on a main document carrying isAccountMain this re-fired on every save. The side effect is skipped on the main document; the request itself still succeeds, so no caller breaks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nA8Pb1y3ytneydAwDTU23 --- api/src/sites/router.ts | 5 +++- tests/features/main-site.api.spec.ts | 34 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/api/src/sites/router.ts b/api/src/sites/router.ts index 57b9805f..6d7b20f8 100644 --- a/api/src/sites/router.ts +++ b/api/src/sites/router.ts @@ -193,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) } diff --git a/tests/features/main-site.api.spec.ts b/tests/features/main-site.api.spec.ts index 1d7d28b2..3a72b9fb 100644 --- a/tests/features/main-site.api.spec.ts +++ b/tests/features/main-site.api.spec.ts @@ -152,6 +152,40 @@ test.describe('main site document', () => { 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') + }) + test('a session on the main host is still a back-office session', async () => { await seedMainSiteDoc() await setCategories(['theme', 'title', 'mails', 'registration']) From a0d35015ba67608387251fb37aa23ff1bf783266 Mon Sep 17 00:00:00 2001 From: Alban Mouton <alban.mouton@gmail.com> Date: Fri, 11 Sep 2026 14:29:31 +0200 Subject: [PATCH 08/14] feat(ui): mark and explain the main site document in the admin pages The sites list badges the main site document and surfaces mainSiteWarnings alongside the colour warnings. Its edit page gains a banner explaining what the document does and does not drive, and lists the warnings. The auth sections stay visible: the form round-trips them, so hiding would conceal the very values the warnings refer to. Only isAccountMain is withheld, being the one field whose write reaches beyond the document. Also strips the computed mainSiteWarnings from the patch body: the patch schema is additionalProperties:false, so leaving it in would have made every save of the main document fail with a 400. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nA8Pb1y3ytneydAwDTU23 --- api/i18n/en.js | 6 ++- api/i18n/fr.js | 4 +- api/types/site/schema.js | 4 ++ tests/features/main-site.api.spec.ts | 8 ++++ tests/features/main-site.e2e.spec.ts | 66 ++++++++++++++++++++++++++++ ui/src/composables/use-store.ts | 8 ++++ ui/src/pages/admin/sites/[id].vue | 29 +++++++++++- ui/src/pages/admin/sites/index.vue | 17 +++++-- 8 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 tests/features/main-site.e2e.spec.ts diff --git a/api/i18n/en.js b/api/i18n/en.js index c4af35a7..29d23770 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: { diff --git a/api/i18n/fr.js b/api/i18n/fr.js index 5083a9c4..104ab492 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: { 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/tests/features/main-site.api.spec.ts b/tests/features/main-site.api.spec.ts index 3a72b9fb..318beea2 100644 --- a/tests/features/main-site.api.spec.ts +++ b/tests/features/main-site.api.spec.ts @@ -184,6 +184,14 @@ test.describe('main site document', () => { 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 () => { diff --git a/tests/features/main-site.e2e.spec.ts b/tests/features/main-site.e2e.spec.ts new file mode 100644 index 00000000..6c0e5c7f --- /dev/null +++ b/tests/features/main-site.e2e.spec.ts @@ -0,0 +1,66 @@ +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 + await expect(page.getByText('Gestion des utilisateurs')).toBeVisible() + + // isAccountMain is the one control not offered on the main document + await expect(page.getByText('Site principal du compte')).toHaveCount(0) + + // 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/ui/src/composables/use-store.ts b/ui/src/composables/use-store.ts index 28737d40..48934286 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 + // $uiConfig.mainSiteFromDb; 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..1989cfac 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 }}</a> - {{ site.data.value?._id }} + <v-alert + v-if="isMainSite" + data-testid="main-site-banner" + type="info" + variant="tonal" + class="my-4" + > + <p>{{ $t('pages.admin.site.mainSiteExplanation') }}</p> + <ul + v-if="site.data.value?.mainSiteWarnings?.length" + data-testid="main-site-warnings" + class="mt-2 ml-4" + > + <li + v-for="(warning, i) of site.data.value.mainSiteWarnings" + :key="i" + > + {{ warning }} + </li> + </ul> + </v-alert> <vjsf-patch-req-body v-model="patch" :locale="locale" @@ -74,6 +95,8 @@ const vjsfOptions = computed(() => { density: 'comfortable', initialValidation: 'always', context: { + isMainSite: isMainSite.value, + mainSiteFromDb: $uiConfig.mainSiteFromDb, 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<string, string[]>) @@ -81,7 +104,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 +112,15 @@ const site = useFetch<SiteWithColorWarnings>($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 4def519c..62039d30 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 ?? ''}` }}</a> + <v-chip + v-if="isMainSiteDoc(props.item)" + size="x-small" + color="primary" + class="ml-2" + > + {{ $t('pages.admin.sites.mainSite') }} + </v-chip> </td> <td>{{ props.item._id }}</td> <td> @@ -114,7 +122,7 @@ :icon="mdiLoginVariant" @click="siteRedirect(props.item)" /> - <v-menu v-if="props.item.colorWarnings.length"> + <v-menu v-if="allWarnings(props.item).length"> <template #activator="{props: colorWarningsMenuProps}"> <v-btn :title="$t('pages.admin.sites.colorWarnings')" @@ -128,7 +136,7 @@ </template> <v-list class="border-sm"> <v-list-item - v-for="(warning, i) of props.item.colorWarnings" + v-for="(warning, i) of allWarnings(props.item)" :key="i" > <v-list-item-title> @@ -146,11 +154,14 @@ <script setup lang="ts"> -type SiteWithColorWarnings = Site & { colorWarnings: string[] } +type SiteWithColorWarnings = Site & { colorWarnings: string[], mainSiteWarnings?: string[] } + +const allWarnings = (site: SiteWithColorWarnings) => [...site.colorWarnings, ...(site.mainSiteWarnings ?? [])] const { t } = useI18n() const sites = useFetch<{ count: number, results: SiteWithColorWarnings[] }>($apiPath + '/sites', { query: { showAll: true } }) const protocol = window.location.protocol +const { isMainSiteDoc } = useStore() const deleteSite = useAsyncAction(async (site: Site) => { await $fetch(`sites/${site._id}`, { method: 'DELETE' }) From 124ad219054615359f96d794859cb7e17e2d3ec9 Mon Sep 17 00:00:00 2001 From: Alban Mouton <alban.mouton@gmail.com> Date: Sat, 12 Sep 2026 15:36:59 +0200 Subject: [PATCH 09/14] test(sites): verify the main-site admin page end to end Locators tightened after a first run against a cold vite compile: the section assertion gets the same generous timeout as the banner, and isAccountMain is matched by label rather than by text because the deprecated authMode field quotes "Site principal du compte" in its own label. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nA8Pb1y3ytneydAwDTU23 --- tests/features/main-site.e2e.spec.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/features/main-site.e2e.spec.ts b/tests/features/main-site.e2e.spec.ts index 6c0e5c7f..a6a2a49a 100644 --- a/tests/features/main-site.e2e.spec.ts +++ b/tests/features/main-site.e2e.spec.ts @@ -43,11 +43,17 @@ test.describe('main site document admin page', () => { 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 - await expect(page.getByText('Gestion des utilisateurs')).toBeVisible() + // 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 - await expect(page.getByText('Site principal du compte')).toHaveCount(0) + // 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') From 05a038f20a145ebc7f9a061fb408dfbfa6eb1503 Mon Sep 17 00:00:00 2001 From: Alban Mouton <alban.mouton@gmail.com> Date: Sat, 12 Sep 2026 15:41:31 +0200 Subject: [PATCH 10/14] docs(sites): document main site config db vs env Records the three resolution paths that disagreed on the publicUrl host, the immutable-cache bug that followed, the MAIN_SITE_FROM_DB category list, and why a write barrier was rejected in favour of reporting. The dev fixtures gain a main site document so the path is exercised locally; MAIN_SITE_FROM_DB is empty by default, so it stays inert until set in .env. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nA8Pb1y3ytneydAwDTU23 --- AGENTS.md | 1 + dev/fixtures.ts | 31 +++++ docs/architecture/main-site-config.md | 173 ++++++++++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 docs/architecture/main-site-config.md 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/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..5e2c042e --- /dev/null +++ b/docs/architecture/main-site-config.md @@ -0,0 +1,173 @@ +# 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 resolver + +`api/src/sites/main-site.ts` owns the merge. Every presentation consumer goes +through it, so paths A and B can no longer disagree: + +- the `/api/sites/_*` endpoints in `api/src/sites/router.ts` +- `getSiteExtraParams` in `api/src/sites/spa-params.ts`, which feeds the served + HTML. It is routed through the resolver **unconditionally**, independent of + `mainSiteFromDb` — with an empty list the resolver returns pure env values, so + both sides agree. This is the cache-hash bug fix. +- `api/src/mails/service.ts` + +Hashes are computed from the content actually served, never from the document's +`updatedAt` alone, so `_hashes` and `/:hash/_theme.css` cannot diverge again. + +The env baseline constants (`defaultThemeCss`, `defaultPublicSiteInfo` and their +hashes) are **kept**: `ui/vite.config.ts` imports them to inject the dev +server's HTML and must not reach into mongo. The resolver returns those same +constants when no category contributes, so dev and prod inject identical hashes. + +### 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. From 7a29427deaa4495d46af76131ee4dfd123dcbfad Mon Sep 17 00:00:00 2001 From: Alban Mouton <alban.mouton@gmail.com> Date: Mon, 21 Sep 2026 11:04:04 +0200 Subject: [PATCH 11/14] fix(test): do not close the shared mongo client in the main-site unit spec initMongo() hands out a process-wide client that every unit spec shares; this spec was the only one calling closeMongo() in afterAll. Nothing noticed until master added orphan-avatars.unit.spec.ts, which sorts right after main-site and calls initMongo() again: the client was already closed, reconnecting it threw MongoNotConnectedError, and the rest of the unit project (13 tests) never ran. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nA8Pb1y3ytneydAwDTU23 --- tests/features/main-site.unit.spec.ts | 7 +++++-- tests/support/unit.ts | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/features/main-site.unit.spec.ts b/tests/features/main-site.unit.spec.ts index ff1615ab..e390e85c 100644 --- a/tests/features/main-site.unit.spec.ts +++ b/tests/features/main-site.unit.spec.ts @@ -4,11 +4,14 @@ import { strict as assert } from 'node:assert' import { test } from '@playwright/test' -import { initMongo, closeMongo } from '../support/unit.ts' +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.afterAll(async () => { await closeMongo() }) test.beforeEach(async () => { const mongo = (await import('../../api/src/mongo.ts')).default 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 From cdc9b6674095e7a537eb4beb3684b5ec2d17e732 Mon Sep 17 00:00:00 2001 From: Alban Mouton <alban.mouton@gmail.com> Date: Mon, 21 Sep 2026 11:59:35 +0200 Subject: [PATCH 12/14] refactor(sites): drop exports and context keys left over from dropped designs Five hunks outlived the designs they were written for during this branch and are unreferenced in the final state: - the VJSF context key mainSiteFromDb, left from a per-section-notes UI that the banner replaced. Nothing read it, which also made the uiConfig entry feeding it dead, so both go. - escapeHtml, module-private in app.ts before it moved to spa-params.ts - lighterTheme, exported for main-site.ts before buildMainPublicSiteInfo moved next to it - isMainSiteUrl and getMainSiteDoc in the #services barrel, which every consumer reaches through ./sites/service.ts instead - the MainSitePresentation pass-through re-export, plus three constants used only inside main-site.ts No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nA8Pb1y3ytneydAwDTU23 --- api/src/services.ts | 2 +- api/src/sites/main-site.ts | 10 ++++------ api/src/sites/spa-params.ts | 2 +- api/src/ui-config.ts | 1 - api/src/utils/public-site-info.ts | 2 +- ui/src/composables/use-store.ts | 2 +- ui/src/pages/admin/sites/[id].vue | 1 - 7 files changed, 8 insertions(+), 12 deletions(-) diff --git a/api/src/services.ts b/api/src/services.ts index 3ab0e2d9..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, isMainSiteUrl, isMainSiteDoc, getMainSiteDoc } 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 index c36978cb..503a6ab9 100644 --- a/api/src/sites/main-site.ts +++ b/api/src/sites/main-site.ts @@ -13,19 +13,17 @@ import { } from '../utils/public-site-info.ts' import { getThemeCss, defaultThemeCss, defaultThemeCssHash } from '../utils/theme.ts' -export type { MainSitePresentation } +type MainSiteCategory = 'theme' | 'title' | 'mails' | 'registration' -export type MainSiteCategory = 'theme' | 'title' | 'mails' | 'registration' - -export const mainSiteCategories: 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). -export const mainSiteIgnoredFields = ['authMode', 'authOnlyOtherSite', 'authProviders', 'applications', 'isAccountMain'] as const +const mainSiteIgnoredFields = ['authMode', 'authOnlyOtherSite', 'authProviders', 'applications', 'isAccountMain'] as const -export const mainSiteCategoryFields: Record<MainSiteCategory, (keyof Site)[]> = { +const mainSiteCategoryFields: Record<MainSiteCategory, (keyof Site)[]> = { theme: ['theme'], title: ['title'], mails: ['mails'], diff --git a/api/src/sites/spa-params.ts b/api/src/sites/spa-params.ts index 024b601e..559e1f08 100644 --- a/api/src/sites/spa-params.ts +++ b/api/src/sites/spa-params.ts @@ -7,7 +7,7 @@ import { getMainSiteResources } from './main-site.ts' // 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. -export const escapeHtml = (value: string) => value +const escapeHtml = (value: string) => value .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') .replace(/\{/g, '{') .replace(/\$/g, '$$$$') diff --git a/api/src/ui-config.ts b/api/src/ui-config.ts index 443f59c6..1cdbae5a 100644 --- a/api/src/ui-config.ts +++ b/api/src/ui-config.ts @@ -8,7 +8,6 @@ export const uiConfig = { publicUrl: config.publicUrl, theme: config.theme, manageSites: config.manageSites, - mainSiteFromDb: config.mainSiteFromDb, i18n: config.i18n, tosUrl: config.tosUrl, passwordless: config.passwordless, diff --git a/api/src/utils/public-site-info.ts b/api/src/utils/public-site-info.ts index 67107f5d..d1a67b16 100644 --- a/api/src/utils/public-site-info.ts +++ b/api/src/utils/public-site-info.ts @@ -12,7 +12,7 @@ const removeUndef = (obj?: Record<string, any>) => { } } -export const lighterTheme = (fullTheme: Theme) => { +const lighterTheme = (fullTheme: Theme) => { const theme = clone(fullTheme) if (!theme.dark) delete theme.darkColors if (!theme.hc) delete theme.hcColors diff --git a/ui/src/composables/use-store.ts b/ui/src/composables/use-store.ts index 48934286..ffaba4f5 100644 --- a/ui/src/composables/use-store.ts +++ b/ui/src/composables/use-store.ts @@ -31,7 +31,7 @@ function createStore () { // a site document is *the main site document* when its host+path matches // publicUrl. Its presentation drives the main site for the categories in - // $uiConfig.mainSiteFromDb; its auth configuration never applies. + // 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 ?? ''}`) diff --git a/ui/src/pages/admin/sites/[id].vue b/ui/src/pages/admin/sites/[id].vue index 1989cfac..f47bea01 100644 --- a/ui/src/pages/admin/sites/[id].vue +++ b/ui/src/pages/admin/sites/[id].vue @@ -96,7 +96,6 @@ const vjsfOptions = computed(() => { initialValidation: 'always', context: { isMainSite: isMainSite.value, - mainSiteFromDb: $uiConfig.mainSiteFromDb, 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<string, string[]>) From 9edf2d9c953332e64e96f70ecf591ae3bcf2fa26 Mon Sep 17 00:00:00 2001 From: Alban Mouton <alban.mouton@gmail.com> Date: Mon, 21 Sep 2026 14:56:22 +0200 Subject: [PATCH 13/14] refactor(sites): render the main site through the ordinary site path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main-site.ts had grown a parallel implementation of what the ordinary site path already does: buildMainPublicSiteInfo alongside getPublicSiteInfo, and a second hash cache with its own key scheme alongside getPublicSiteInfoHash / getThemeCssHash. That forced every consumer to branch, leaving 11 "ordinary : main" ternaries across router.ts and spa-params.ts. The merge now returns an EffectiveSite — Site with an optional owner and a main flag — and every consumer renders it with the same functions as a real site: const site = await reqSite(req) ?? await getEffectiveMainSite() res.send(getPublicSiteInfo(site)) main-site.ts drops to the merge plus the warnings; the ternaries are gone. Logo precedence is resolved inside the merge so the shared renderer stays dumb, and the contributing categories are folded into the synthetic _id so the shared hash caches still key correctly. Ordinary sites are unaffected: their public info is byte identical, hash included, verified against the previous implementation. The main site's own payload gains the keys the shared shape always emitted, so defaultPublicSiteInfoHash moves once — a single cache miss on upgrade. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nA8Pb1y3ytneydAwDTU23 --- api/src/mails/service.ts | 12 +-- api/src/sites/main-site.ts | 125 ++++++++++---------------- api/src/sites/router.ts | 39 ++++---- api/src/sites/spa-params.ts | 22 ++--- api/src/test-env.ts | 4 +- api/src/utils/public-site-info.ts | 75 ++++++++-------- api/src/utils/theme.ts | 8 +- docs/architecture/main-site-config.md | 54 +++++++---- tests/features/main-site.unit.spec.ts | 57 ++++++------ 9 files changed, 199 insertions(+), 197 deletions(-) diff --git a/api/src/mails/service.ts b/api/src/mails/service.ts index c11b52d8..fa06ced7 100644 --- a/api/src/mails/service.ts +++ b/api/src/mails/service.ts @@ -7,7 +7,7 @@ import { flatten } from 'flat' import EventEmitter from 'node:events' import mailsTransport from './transport.ts' import { getSiteByUrl, getSiteByHost, isMainSiteDoc } from '#services' -import { getMainSitePresentation } from '../sites/main-site.ts' +import { getEffectiveMainSite } from '../sites/main-site.ts' import { internalError } from '@data-fair/lib-node/observer.js' import { mailLimiter } from '../utils/limiter.ts' @@ -123,11 +123,11 @@ export const sendMail = async (to: string, params: SendMailParams, attachments?: let template = params.htmlButton ? mainSiteTemplate : mainSiteNoButtonTemplate if (mainSite) { - const presentation = await getMainSitePresentation() - Object.assign(flatTheme, flatten({ theme: presentation.theme })) - logo = presentation.theme.logo || logo - from = presentation.mails.from ?? from - contact = presentation.mails.contact ?? contact + 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 diff --git a/api/src/sites/main-site.ts b/api/src/sites/main-site.ts index 503a6ab9..f4139500 100644 --- a/api/src/sites/main-site.ts +++ b/api/src/sites/main-site.ts @@ -1,17 +1,9 @@ -import crypto from 'node:crypto' -import serialize from 'serialize-javascript' import config from '#config' -import { type Site, type SitePublic } from '#types' +import { type Site } from '#types' import { getMessage } from '#i18n' import { getMainSiteDoc } from './service.ts' -import { - type MainSitePresentation, - envMainSitePresentation, - buildMainPublicSiteInfo, - defaultPublicSiteInfo, - defaultPublicSiteInfoHash -} from '../utils/public-site-info.ts' -import { getThemeCss, defaultThemeCss, defaultThemeCssHash } from '../utils/theme.ts' +import { type EffectiveSite, envMainSite, clearPublicSiteInfoHashCache } from '../utils/public-site-info.ts' +import { clearThemeCssHashCache } from '../utils/theme.ts' type MainSiteCategory = 'theme' | 'title' | 'mails' | 'registration' @@ -34,81 +26,60 @@ const mainSiteCategoryFields: Record<MainSiteCategory, (keyof Site)[]> = { // PATCH /api/test-env/config const enabled = (category: MainSiteCategory) => config.mainSiteFromDb.includes(category) -export const getMainSitePresentation = async (): Promise<MainSitePresentation> => { +/** + * 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[] = [] - const presentation = envMainSitePresentation() - if (doc) { - if (enabled('theme') && doc.theme) { - presentation.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 (!presentation.theme.logo && !config.theme.logo) { - presentation.theme = { ...presentation.theme, logo: `/simple-directory/api/avatars/${doc.owner.type}/${doc.owner.id}/avatar.png` } - } - used.push('theme') - } - if (enabled('title') && doc.title) { - presentation.title = doc.title - used.push('title') + 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` } } - if (enabled('mails') && doc.mails) { - presentation.mails = { - from: doc.mails.from ?? presentation.mails.from, - contact: doc.mails.contact ?? presentation.mails.contact - } - used.push('mails') - } - if (enabled('registration') && (doc.tosMessage !== undefined || doc.reducedPersonalInfoAtCreation !== undefined)) { - presentation.tosMessage = doc.tosMessage - presentation.reducedPersonalInfoAtCreation = doc.reducedPersonalInfoAtCreation - used.push('registration') + 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 } - if (used.length) presentation.docKey = `${doc._id}-${doc.updatedAt}-${used.join(',')}` + used.push('mails') + } + if (enabled('registration') && (doc.tosMessage !== undefined || doc.reducedPersonalInfoAtCreation !== undefined)) { + site.tosMessage = doc.tosMessage + site.reducedPersonalInfoAtCreation = doc.reducedPersonalInfoAtCreation + used.push('registration') } - return presentation -} - -type MainSiteResources = { - publicInfo: SitePublic & { main: true }, - publicInfoHash: string, - themeCss: string, - themeCssHash: string -} - -// the env baseline, shared with ui/vite.config.ts so dev and prod inject the -// same hashes when no document contributes -const envResources: MainSiteResources = { - publicInfo: defaultPublicSiteInfo, - publicInfoHash: defaultPublicSiteInfoHash, - themeCss: defaultThemeCss, - themeCssHash: defaultThemeCssHash -} - -const resourcesCache: Record<string, MainSiteResources> = {} -// Hashes are computed from the content actually served, so _hashes and -// /:hash/_theme.css cannot diverge (they used to: app.ts read the document -// while the endpoints read env, and the mismatched hash was cached immutable -// for a year). -export const getMainSiteResources = async (): Promise<MainSiteResources> => { - const presentation = await getMainSitePresentation() - if (!presentation.docKey) return envResources - if (!resourcesCache[presentation.docKey]) { - const publicInfo = buildMainPublicSiteInfo(presentation) - const themeCss = getThemeCss(presentation.theme) - resourcesCache[presentation.docKey] = { - publicInfo, - publicInfoHash: crypto.createHash('md5').update(serialize(publicInfo)).digest('hex'), - themeCss, - themeCssHash: crypto.createHash('md5').update(themeCss).digest('hex') - } + // 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 resourcesCache[presentation.docKey] + return site } -export const clearMainSiteCache = () => { - for (const key of Object.keys(resourcesCache)) delete resourcesCache[key] +// 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. diff --git a/api/src/sites/router.ts b/api/src/sites/router.ts index 6d7b20f8..173585df 100644 --- a/api/src/sites/router.ts +++ b/api/src/sites/router.ts @@ -14,7 +14,7 @@ import Debug from 'debug' import { cipher } from '../utils/cipher.ts' import { type OpenIDConnect } from '#types/site/index.ts' import { getPublicSiteInfo, getPublicSiteInfoHash } from '../utils/public-site-info.ts' -import { getMainSiteResources, getMainSitePresentation, getMainSiteWarnings } from './main-site.ts' +import { getEffectiveMainSite, getMainSiteWarnings } from './main-site.ts' import serialize from 'serialize-javascript' const debugPostSite = Debug('post-site') @@ -216,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) : (await getMainSiteResources()).publicInfo - 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) : (await getMainSiteResources()).publicInfo + const site = await reqSite(req) ?? await getEffectiveMainSite() + const publicSiteInfo = getPublicSiteInfo(site) res.contentType('application/javascript') res.send(`window.__PUBLIC_SITE_INFO=${serialize(publicSiteInfo)}`) }) @@ -233,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) : (await getMainSiteResources()).publicInfo + 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((await getMainSitePresentation()).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 ?? '') : (await getMainSiteResources()).themeCss + const site = await reqSite(req) ?? await getEffectiveMainSite() + const css = getThemeCss(site.theme, site.path ?? '') res.contentType('css') res.send(css) }) @@ -257,27 +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 ?? '') : (await getMainSiteResources()).themeCss + 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 mainResources = site ? undefined : await getMainSiteResources() + const site = await reqSite(req) ?? await getEffectiveMainSite() res.send({ - publicInfo: site ? getPublicSiteInfoHash(site) : mainResources!.publicInfoHash, - themeCss: site ? getThemeCssHash(site) : mainResources!.themeCssHash, - preloadLinks: site?.theme.preloadLinks ?? (await getMainSitePresentation()).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) - const theme = site?.theme ?? (await getMainSitePresentation()).theme - res.send(getSiteColorsWarnings(localeCode as 'fr' | 'en', 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/spa-params.ts b/api/src/sites/spa-params.ts index 559e1f08..2510db0c 100644 --- a/api/src/sites/spa-params.ts +++ b/api/src/sites/spa-params.ts @@ -1,7 +1,7 @@ import { getSiteByUrl, isMainSiteUrl } from './service.ts' import { getThemeCssHash } from '../utils/theme.ts' import { getPublicSiteInfoHash } from '../utils/public-site-info.ts' -import { getMainSiteResources } from './main-site.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 @@ -15,20 +15,20 @@ const escapeHtml = (value: string) => value /** * Values injected into the served index.html for a given site URL. * - * This mirrors reqSite(): on the main host the document is resolved through - * getMainSiteResources, never read raw. 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. + * 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) - const mainResources = site ? undefined : await getMainSiteResources() + const site = (isMainSiteUrl(siteUrl) ? undefined : await getSiteByUrl(siteUrl)) ?? await getEffectiveMainSite() return { - THEME_CSS_HASH: site ? getThemeCssHash(site) : mainResources!.themeCssHash, - PUBLIC_SITE_INFO_HASH: site ? getPublicSiteInfoHash(site) : mainResources!.publicInfoHash, + THEME_CSS_HASH: getThemeCssHash(site), + PUBLIC_SITE_INFO_HASH: getPublicSiteInfoHash(site), // 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 || mainResources?.publicInfo.title || 'Simple Directory') + SITE_TITLE: escapeHtml(site.title || 'Simple Directory') } } diff --git a/api/src/test-env.ts b/api/src/test-env.ts index 964aa2e1..5b7f3610 100644 --- a/api/src/test-env.ts +++ b/api/src/test-env.ts @@ -153,10 +153,10 @@ router.post('/run-user-cleanup', async (req, res) => { // the derived main-site resources router.post('/clear-site-cache', async (req, res) => { const { getSiteByHost, getMainSiteDoc } = await import('./sites/service.ts') - const { clearMainSiteCache } = await import('./sites/main-site.ts') + const { clearSiteResourceCaches } = await import('./sites/main-site.ts') getSiteByHost.clear() getMainSiteDoc.clear() - clearMainSiteCache() + 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 d1a67b16..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<Site, 'owner'> & { 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, @@ -40,54 +71,24 @@ export const getPublicSiteInfo = (site: Site): SitePublic => { 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<string, string> = {} -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 type MainSitePresentation = { - theme: Theme, - title?: string, - tosMessage?: string, - reducedPersonalInfoAtCreation?: boolean, - mails: { from?: string, contact?: string }, - // identity of the document actually contributing, used as a cache key; - // undefined when every value comes from the environment - docKey?: string -} - -export const envMainSitePresentation = (): MainSitePresentation => ({ - theme: config.theme, - mails: { from: config.mails.from, contact: config.contact } -}) - -// The main site is always a locally authenticated back-office, whatever its -// document says: authMode, authProviders, owner and isAccountMain are never -// taken from it. See docs/architecture/main-site-config.md -export const buildMainPublicSiteInfo = (presentation: MainSitePresentation): SitePublic & { main: true } => { - const info: Record<string, any> = { - main: true, - host: publicHost, - theme: lighterTheme(presentation.theme), - title: presentation.title, - tosMessage: presentation.tosMessage, - reducedPersonalInfoAtCreation: presentation.reducedPersonalInfoAtCreation, - isAccountMain: true, - authMode: 'onlyLocal' - } - removeUndef(info) - return info as SitePublic & { main: true } +export const clearPublicSiteInfoHashCache = () => { + for (const key of Object.keys(publicSiteInfoHashCache)) delete publicSiteInfoHashCache[key] } -export const defaultPublicSiteInfo = buildMainPublicSiteInfo(envMainSitePresentation()) +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<string, string> = {} -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/docs/architecture/main-site-config.md b/docs/architecture/main-site-config.md index 5e2c042e..217aa044 100644 --- a/docs/architecture/main-site-config.md +++ b/docs/architecture/main-site-config.md @@ -96,25 +96,47 @@ and user scoping. `getPublicSiteInfo` for the main site forces `main: true`, The default is `[]` in 8.x, so no existing install changes behaviour on upgrade. 9.0 will default to the full list. -## One resolver +## One merge, no second rendering path -`api/src/sites/main-site.ts` owns the merge. Every presentation consumer goes -through it, so paths A and B can no longer disagree: +`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: -- the `/api/sites/_*` endpoints in `api/src/sites/router.ts` -- `getSiteExtraParams` in `api/src/sites/spa-params.ts`, which feeds the served - HTML. It is routed through the resolver **unconditionally**, independent of - `mainSiteFromDb` — with an empty list the resolver returns pure env values, so - both sides agree. This is the cache-hash bug fix. -- `api/src/mails/service.ts` - -Hashes are computed from the content actually served, never from the document's -`updatedAt` alone, so `_hashes` and `/:hash/_theme.css` cannot diverge again. +```ts +const site = await reqSite(req) ?? await getEffectiveMainSite() +res.send(getPublicSiteInfo(site)) +``` -The env baseline constants (`defaultThemeCss`, `defaultPublicSiteInfo` and their -hashes) are **kept**: `ui/vite.config.ts` imports them to inject the dev -server's HTML and must not reach into mongo. The resolver returns those same -constants when no category contributes, so dev and prod inject identical hashes. +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-<docId>-<categories>`). 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 diff --git a/tests/features/main-site.unit.spec.ts b/tests/features/main-site.unit.spec.ts index e390e85c..a8093ac0 100644 --- a/tests/features/main-site.unit.spec.ts +++ b/tests/features/main-site.unit.spec.ts @@ -1,6 +1,7 @@ // The main site document is the site whose host+path matches publicUrl. -// getMainSitePresentation merges its presentation fields over config.* -// according to config.mainSiteFromDb, and nothing else. +// 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' @@ -17,9 +18,9 @@ test.describe('main site document resolver', () => { 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 { clearMainSiteCache } = await import('../../api/src/sites/main-site.ts') + const { clearSiteResourceCaches } = await import('../../api/src/sites/main-site.ts') getMainSiteDoc.clear() - clearMainSiteCache() + clearSiteResourceCaches() }) const seedMainSiteDoc = async (doc: any = {}) => { @@ -46,8 +47,8 @@ test.describe('main site document resolver', () => { 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 { clearMainSiteCache } = await import('../../api/src/sites/main-site.ts') - clearMainSiteCache() + const { clearSiteResourceCaches } = await import('../../api/src/sites/main-site.ts') + clearSiteResourceCaches() } test('finds the document sitting on the publicUrl host', async () => { @@ -66,33 +67,34 @@ test.describe('main site document resolver', () => { await seedMainSiteDoc() await withCategories([]) const config = (await import('../../api/src/config.ts')).default - const { getMainSitePresentation } = await import('../../api/src/sites/main-site.ts') - const presentation = await getMainSitePresentation() + 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.mails?.from, config.mails.from) assert.equal(presentation.tosMessage, undefined) - assert.equal(presentation.docKey, 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 { getMainSitePresentation } = await import('../../api/src/sites/main-site.ts') + const { getEffectiveMainSite } = await import('../../api/src/sites/main-site.ts') await withCategories(['theme']) - let presentation = await getMainSitePresentation() + let presentation = await getEffectiveMainSite() assert.equal(presentation.theme.colors.primary, '#FF00FF') assert.equal(presentation.title, undefined) - assert.equal(presentation.mails.from, config.mails.from) + assert.equal(presentation.mails?.from, config.mails.from) await withCategories(['title', 'mails', 'registration']) - presentation = await getMainSitePresentation() + 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.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) }) @@ -100,8 +102,9 @@ test.describe('main site document resolver', () => { test('never exposes trust-bearing fields', async () => { await seedMainSiteDoc({ authProviders: [{ type: 'saml2', title: 'evil' }], applications: [{ id: 'x' }] }) await withCategories(['theme', 'title', 'mails', 'registration']) - const { getMainSiteResources } = await import('../../api/src/sites/main-site.ts') - const { publicInfo } = await getMainSiteResources() + 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) @@ -114,9 +117,11 @@ test.describe('main site document resolver', () => { await seedMainSiteDoc() await withCategories(['theme']) const crypto = await import('node:crypto') - const { getMainSiteResources } = await import('../../api/src/sites/main-site.ts') - const { themeCss, themeCssHash } = await getMainSiteResources() - assert.equal(crypto.createHash('md5').update(themeCss).digest('hex'), themeCssHash) + 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 @@ -128,15 +133,17 @@ test.describe('main site document resolver', () => { await seedMainSiteDoc() const config = (await import('../../api/src/config.ts')).default const { getSiteExtraParams } = await import('../../api/src/sites/spa-params.ts') - const { getMainSiteResources } = await import('../../api/src/sites/main-site.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 resources = await getMainSiteResources() - assert.equal(params.THEME_CSS_HASH, resources.themeCssHash, `categories=${categories.join(',')}`) - assert.equal(params.PUBLIC_SITE_INFO_HASH, resources.publicInfoHash, `categories=${categories.join(',')}`) + 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']) From 4998afe74899faa471a5f50eab7939ffee346505 Mon Sep 17 00:00:00 2001 From: Alban Mouton <alban.mouton@gmail.com> Date: Mon, 21 Sep 2026 15:42:17 +0200 Subject: [PATCH 14/14] fix(test): reset the per-recipient mail budget between test runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DELETE /api/test-env cleaned the auth and contact rate limiters but not the mail one, whose window is the only one that outlives a run: the dev server uses NODE_ENV=development, so the limiter takes the production default of 500 mails per recipient per day rather than the small test.cjs window. Shared fixture addresses burn a few points per run, so after enough runs admin@test.com hit 509/500 and stayed blocked for the rest of the day. The symptom lands far from the cause — 2fa.api.spec.ts failing on `waitForMail timeout` while the mail was in fact dropped by the limiter, with the reason only visible in the server log. mails-rate-limit.api.spec.ts is unaffected: it pre-fills its own bucket inside each test, with a unique recipient, after this cleanup runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nA8Pb1y3ytneydAwDTU23 --- api/src/test-env.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/api/src/test-env.ts b/api/src/test-env.ts index 5b7f3610..0d62da08 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