Imperium hosting
- Configs are declarative mode+data JSON (not JSX), so there is NO server-side compilation / vm / AST validation. The safe schema is the config. Resolving a host = a DB read + a gate check.
Build-knowledge — Imperium hosting (Studio dashboard + production serving)
How a Leumas tenant hosts their own Imperium sites — set up, edit (any mode, live preview), publish to production, connect external apps — and how a white-label buyer runs the same thing in their own ecosystem. This is the build-knowledge entry for the hosting spine.
The mental model
config doc (api.db('configs')) = one subdomain
{ id(=subdomain), appName, mode, data, seo, hosting:{ ownerUserId, tenantId, status, subdomain, domains } }
│
Studio ▸ Hosting ──-> /api/hosting/* ← @leumas/hosting (owner+tenant CRUD + publish lifecycle)
useCapability('imperium.hosting') gated by ↓
@leumas/entitlements (policy → capabilities + quotas, DATA)
visitor {sub}.leumas.tech ──-> /api/hosting/resolve?host= ← same @leumas/hosting core (published+entitled gate)
→ DynamicPage renders mode+data (+ per-config SEO/sitemap/robots)
standalone white-label: @leumas/imperium-server imports the SAME @leumas/hosting core (one core, two deployments)
- Configs are declarative
mode+dataJSON (not JSX), so there is NO server-side compilation /
vm / AST validation. The safe schema is the config. Resolving a host = a DB read + a gate check.
The three new units
@leumas/entitlements(shared/packages/entitlements) — the swappable membership-ACCESS axis
(sibling to PassNode's monetization axis). Plans declare capabilities (booleans) + quota limits (numbers) as DATA in the Studio-editable entitlement_policies collection (one doc per plan/service), with per-user overrides in membership.metadata. Resolver merges active memberships (OR booleans, MAX limits). Backend: entitlements.requireCapability(cap) / checkQuota(user, cap, count) / forUser. Frontend: @leumas/features useCapability(cap) / useLimit(cap) / <RequireCapability cap fallback> off GET /entitlements/me. Wired in app.js beside PassNode (load + refresh on the /db change-feed). Change a rule = edit a doc; zero code change.
@leumas/hosting(shared/engines/plugins/hosting) — the hosting core:router.js(/api/hosting
owner+tenant CRUD, publish/unpublish/suspend, subdomain claim, domains, proxy-check, telemetry), resolve.js (host→published+owner-entitled config or suspension fallback; strips secrets), seo.js (meta + sitemap.xml + sitemap-index + robots.txt), serve.js (public resolve + SEO routers), proxy.js (SSRF-safe reverse proxy — denies RFC1918/loopback/link-local/ULA/metadata/CGNAT, DNS-rebind pin, strips visitor Cookie/Authorization, timeout/size caps, gated by imperium.reverseProxy; plus createProxyUpgradeHandler so WebSockets — HMR, socket.io — survive the proxy under the same rules), targets.js (what a host serves: config · proxy · server · static · bundle · redirect), localApps.js (the server kind's supervisor — spawns a member's app from a folder+port, restarts it with backoff, keeps a log ring; self-host only, inert unless the deployment passes allowSpawn), subdomains.js (normalize/reserved/host helpers).
mode vs target — the name collision worth knowing. A mode decides what a page renders; a target decides whether a page is rendered at all. mode:'server' frames someone's app in an iframe. target.kind:'server' is the app, served as the domain itself. Both exist; they are not alternatives to each other. The dispatcher is mounted by leumas-api and imperium-server, so a target behaves identically on an appliance and on a white-label box. Guard: pnpm smoke:hosting.
@leumas/imperium-server(shared/services/imperium-server) — the standalone white-label edition;
createImperiumApp() reuses the SAME @leumas/hosting resolve/SEO, injects per-subdomain SEO into the web bundle's index.html, exposes /healthz+/readyz, and sits behind the wildcard deploy/Caddyfile (*.leumas.tech → identical worker pool, preserve Host). v1.0: bin/create-imperium.mjs scaffolds an instance (runtime layout + generated secrets + seeded __global and first site, idempotent), README.md is the host-your-own-infra guide, entitlements come from a Leumas-signed offline token on a licensed appliance or local policies + committed defaults when self-hosted. Package it with node ops/tools/exe-builder/build-imperium.mjs (pnpm deploy hoisted — symlinked deps would break the moment the folder is copied) → a relocatable ~117 MB bundle with start.cmd/start.sh.
Nested multi-tenancy
config.hosting.ownerUserId + config.hosting.tenantId (req.user.tenantId from users.metadata, default 'leumas'). Scope in the hosting router: end-user sees own · tenant-admin (role tenant_admin or capability imperium.tenantAdmin) sees the whole tenant · Leumas admin sees all. A white-label buyer is a tenant whose members host under imperium.* capabilities their plan grants.
Studio surfaces (products/leumas-studio/src/admin/hosting/)
WebsitesHome ("My Websites": cards with live DynamicPage thumbnails, status, quota, draft-preview links) · CreateSiteWizard (/admin/site-wizard: template-or-blank → live subdomain claim → schema form beside the live preview → publish) · SiteEditor (tabs: Content · SEO · Domains · Connect · Shield · Analytics · Danger) · GlobalSettings (the __global layer) · ViewModes (the view-type index + custom-type upload) · OperatorTab (admin cross-lane inventory) · PreviewFrame · HostingPaywall. The whole domain is gated by ONE <RequireCapability cap="imperium.hosting">. ConfigManager survives as the operator raw-JSON lane — it writes through the hosting router, never api.db('configs').
Modes — the data contract
Every mode's data shape is DECLARED once in config-registry/src/dataSchemas.js (descriptor fields). That single source drives: the generated Studio form (@leumas/ui SchemaForm), server-side validation on every save/publish (validateData → 400 with a field-level error list), the new-site template, and the docs. Every built-in mode has a renderer, and a guard asserts both directions of that — no mode without a renderer, no schema without a mode. The count is deliberately not written here. It was "29" in this file while the real number was 34, and the same stale figure is repeated in four other places; a number in prose is a future lie. The live list is at docs.leumas.tech/modes, generated from MODES at build time.
User-uploaded view types: a doc in the view_modes collection carries its own field schema + default template + sandboxed HTML renderer (x- namespace, CustomModePage). It flows through the SAME validation, SchemaForm and DynamicPage path as a built-in — zero new engine code. Anonymous visitors fetch them from the PUBLIC GET /api/hosting/view-modes (/db is auth-gated and would 401 a first-time visitor).
Serving guarantees
A config serves only when published (hosted lane) or explicitly firstParty: true (operator lane) — an unclaimed raw doc 404s. Served output is the merge __global → __global--<tenantId> → site (local wins, arrays concat), behind a TTL read-cache that every write busts. Drafts are visible only through a signed 30-minute preview-token, noindexed. Storage-backed modes (folder/gallery/exe) read a per-site root under IMPERIUM_STORAGE_ROOT, traversal-guarded; raw filesystem roots are self-host-only and env-allowlisted.
Extend it
- New capability/quota → add a key to an
entitlement_policiesdoc (data). Read it viauseCapability/
useLimit (frontend) or requireCapability/getLimit (backend).
- New built-in mode → add the page shell, register it in
DynamicPage(or a packageregisterModes.js), and
add its dataSchemas.js entry (fields + template). check-imperium FAILS on a mode without either.
- New view type without touching code → upload it in Studio ▸ View Modes.
- Agents drive it through the
imperiumadapter (auto MCP + functioncalls) and the Leviathan page
capabilities on the Websites surfaces.
- Guards:
pnpm check:imperium(schemas, renderers, templates, write-lane, component-registry drift) ·
pnpm smoke:imperium (full e2e through the standalone server).
- Migration ledgers:
ops/migration/imperium-hosting.json,imperium-modes.json,imperium-configs.json.