Skip to content

feat(sites): drive main site presentation from its db document - #159

Merged
albanm merged 16 commits into
masterfrom
chore-main-site-theme
Sep 29, 2026
Merged

albanm merged 16 commits into
masterfrom
chore-main-site-theme

Conversation

@albanm

@albanm albanm commented Sep 29, 2026

Copy link
Copy Markdown
Member

Adds the notion of a main site document — a site whose host+path matches publicUrl — and lets it drive the main site's presentation through a new opt-in MAIN_SITE_FROM_DB category list (theme, title, mails, registration), falling back to env config per category.

Why: an install whose publicUrl host also carried a site document had two writable sources of config for the same host, and three resolution paths disagreeing about which won — so a portal on the main domain silently lost its theme, while its document still drove the served <title> and the transactional mail sender.

reqSite() is deliberately unchanged and still returns undefined on that host, so isAdmin = !user.host, getUserByEmail scoping and adminMode gating are untouched. authMode, authProviders, applications, isAccountMain and owner are never read from the document; they are reported as ignored (mainSiteWarnings on the sites API, a boot check, an admin banner) rather than refused, because the admin form round-trips the whole document and a write barrier would reject an idempotent save.

The merge returns an ordinary EffectiveSite, so the main host renders through the same getPublicSiteInfo / getThemeCss / hash functions as any other site — there is no second rendering path.

Defaults to [], so no existing install changes behaviour on upgrade. See docs/architecture/main-site-config.md.

Also fixes three pre-existing bugs surfaced by the same dual-source problem:

  • the SPA injected a THEME_CSS_HASH taken from the document while /api/sites/:hash/_theme.css served the env CSS — the hash did not describe the bytes served under it, cached immutable for a year
  • reducedPersonalInfoAtCreation was missing from getPublicSiteInfo, so the setting never reached the login page on any site
  • toggleMainSite re-fired on every full-document save of the main document, rewriting the owner's other sites

Heads-up: the reducedPersonalInfoAtCreation fix is not scoped to the main site — any existing secondary site storing true will start hiding the name fields on its signup form. Enabling the theme category also propagates the theme to every other data-fair service on that host, since they all read /api/sites/_hashes. defaultPublicSiteInfoHash changes once, costing a single _public.js cache miss per install; ordinary sites' payloads and hashes are byte identical.

albanm and others added 16 commits September 11, 2026 14:07
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nA8Pb1y3ytneydAwDTU23
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nA8Pb1y3ytneydAwDTU23
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nA8Pb1y3ytneydAwDTU23
…rved

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/<document-hash>/_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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nA8Pb1y3ytneydAwDTU23
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
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
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
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
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
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
… 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
… 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
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
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
@albanm
albanm merged commit fa757f9 into master Sep 29, 2026
4 checks passed
@albanm
albanm deleted the chore-main-site-theme branch September 29, 2026 14:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant