inmake DOCS
Docs / Tenant hosting / Architecture

Architecture

Host routing, the directory, the token model, provisioning.

One always-on Node server that hosts the published web site of every store with the web capability, each on its own subdomain:

  • https://{sid}.inbs.dev — the store's PROD site,
  • https://demo-{sid}.inbs.dev — the store's DEMO site,
  • plus any custom domains bound DNS-first via a _drop.{domain} TXT record (drop-site={env}.{sid}.{site_key} — see docs/DOMAINS.md).

Internally it is a host router over a fleet of per-store "mini servers": each mounted store gets its own WebSite (from drop-core-server) with its own per-store access token, DataRepository cache, render/deps caches and hook feed, held in an LRU — the same isolation examples/instore has as a standalone process, multiplexed into one Node process and picked per request by the Host header.

Architecture#

Host header ──▶ TenantDirectory.parseHost      (pure syntax: subdomain = sid,
                     │                          demo- prefix = env, reserved
                     ▼                          labels rejected)
                lookup(env, sid) ──────────────▶ POST {core}/system/tenant/resolve
                     │                          (master token, that env's API;
                     ▼                          negative-cached misses)
                TenantMount {token, hookKey}
                     │
                     ▼
                WebPreviewServer (per env) ────▶ WebSite (per store, LRU)
                     siteOptions injection:       reads: 'public'
                     accessToken = store token    headerStore = sid
  • TenantDirectory — host → {env, sid} → credentials. Resolution is a master-token call to /system/tenant/resolve; unknown hosts negative- cache (60 s), a periodic sweep (10 min) re-resolves mounted entries, and credential drift (reroll) evicts the store's WebSite via onChange (a serving 5xx also triggers a throttled background re-resolve, so a rerolled token self-heals on the next request instead of waiting for the sweep). The sweep at scale (plans/hosting/tenant-sweep-and-exposure.md): one pass at a time, ever — a guarded, self-rescheduling timer chain (the next pass is due startedAt + recheckMs, or endedAt + 30 s after a pass that overran, logged TENANT SWEEP OVERRUN {durationMs, mounts}); the warm boot is pass 0 and the first sweep is scheduled from its END; a pass snapshots the mount keys and resolves them on a bounded pool (TENANT_SWEEP_CONCURRENCY, default 8, clamped 1–32 — a key invalidated mid-pass is skipped), then re-checks the custom-domain TXTs on a pool of 16; every /system/tenant/* call has a timeout (TENANT_CORE_TIMEOUT_MS, default 10 s — a timeout is a transport error: the old mount stays, never a 404), which also bounds a visitor's first request. The same value bounds every store site's core READS (WebSiteOptions.coreTimeoutMs — the render, meta and data reads; checkout, cart, forms, /fn and provider calls stay untimed, plans/tenthousand W9 E-D7). One set of mount rules (applyPayload: 404 → drop, transport / 5xx → keep, token change → onChange) serves the single resolve, the sweep and warm boot alike. Each pass leaves {pass, startedAt, durationMs, mounts, resolved, dropped, failed, skipped, overran, hosts, unbound} as the last sweep, shown on /-/status only. Off-zone hosts (kind: 'external') bind DNS-first: _drop.{host} TXT → drop-site={env}.{sid}.{site_key} → resolve → the TXT's key must match the store's site_key (mutual intent: the TXT proves domain-owner intent, the unguessable key proves store-owner intent). No server-side domain registry; DNS is soft state — transport failures never unbind, only two consecutive sweeps that positively confirm a missing/mismatched record do. The first bound domain becomes the store's canonical siteBaseUrl.

  • TenantServer — the http handler. Store hosts dispatch into that env's WebPreviewServer engine (the full route table: pages, /ref/, slugs, robots/sitemaps, /assets/**, /store/{sid}/css|js/**, POST /form/:locale/:form — and GET on the same path, which issues the form guard's fields ({fields}, no-store) for the page's submit script to fetch at load — POST /content/:locale, POST /variant/:locale, POST /cart/:locale + POST /checkout/:locale (+ /checkout/:locale/payment) — the storefront cart/checkout proxies riding each store's own {public, process} serving token — GET|POST|PUT|DELETE /m/{moduleId}/**, an installed module's storefront leg forwarded to POST /api/public/module over the same serving token, the module's answer re-emitted — and GET|POST|PUT|DELETE|OPTIONS /fn/{serviceId}/**, the store's services, run in this machine's sandbox (see "Store services" below); a cacheable GET answer of either sits in the store's edge disk slot; m and fn are reserved first segments, never slugs). Store hosts are served non-indexable: {sid}.inbs.dev / demo-{sid}.inbs.dev answer a constant Disallow: / robots.txt, 404 the sitemaps and carry x-robots-tag: noindex on every response, whatever the store's setup/web.robots says — search indexing is a custom-domain feature, and once a domain is bound the subdomain serves the very same pages. Custom domains (kind: 'external') are indexable per the store's own config, except on the Demo env, which is a copy of the prod store and is never indexable.

    • GET|HEAD /healthz — liveness only, on every host (Fly's checker probes without the canonical Host): exactly {"ok":true} (HEAD: no body). No uptime, build, mounts or env names — nothing public says what runs here (plans/hosting/tenant-sweep-and-exposure.md X-D1).
    • Any unhandled error answers 500 {"error":"internal"} (the message and stack go to the log only — X-D4).

    The canonical host (tenant.inbs.dev) carries the cross-store surfaces. Every ops route authenticates first (authenticateOps: the access-token header against the configured env masters, constant-time) — 401 before any body is read — and acts only on the env whose master matched (X-D3):

    • GET /-/status[?list=1] — the ops status, scoped to that env: {ok, uptimeMs, build, env, mounts, domains, sites, sweep, sweeping, nextSweepMs, services: {mode, sandbox, reports}} — counts only; ?list=1 adds that env's sids (a Demo master never sees a PROD sid). No CORS (no browser caller). On any other host it is an ordinary store path (404). build is the build stamp — the short sha the running server was built from, whether that tree had uncommitted changes, and the build's ms epoch (src/build_info.ts, read once at startup): from TENANT_BUILD_COMMIT / TENANT_BUILD_DIRTY / TENANT_BUILD_AT (the Docker image bakes them from the deploy line's build args), else from the release.json a vshosting release carries at its root; commit: '' when neither is there. tools/deploys reads it here with the env master. services.sandbox = the machine's executor counters (slots, busy, queued, restarts, busyRefusals, calls), services.reports = this env's report batcher.
    • POST /-/mount {env, sid} — master-token-authenticated poke to mount / refresh a store immediately (the admin calls it after provisioning). env must be the authenticated env (else 403) and sid a store id (SID_PATTERN, else 400 invalid_sid) — checked before any lookup or cache wipe; wipeStoreCaches additionally refuses any path that does not resolve strictly inside {dir}/{env}/{sid} (services plan H1). A rebuild keeps the store's disk slots. POST /-/mount {env, sid, flush: true} additionally calls the site's WebSite.flushAll() and wipes the store's disk slots under {dir}/{env}/{sid}/ — the lever that ends any staleness after a lost push (the console's Refresh site caches).
    • POST /-/mount {env, domain[, remove]} — custom-domain connect: forces the _drop TXT validation and, on success, either registers the domain's Fly certificate (FlyCerts, needs FLY_API_TOKEN + FLY_APP_NAME) or, with TENANT_CERTS=probe, checks TLS managed by an external reverse proxy. remove always unbinds; only Fly mode also deletes a certificate. GET /-/domain?domain=… reports cert status for the admin chips. Both answer CORS (the master token is the gate; direct-dev admin calls them from the browser).
    • POST /hook/{env}/{sid} — the MULTI-EVENT hook ingress (what the managed all entries call): forwarded to the matched site's receiveEventHook, which dispatches on x-drop-event (data / files / templates / elements / setup / slug / services; unknown kinds no-op-accept). Only the canonical host takes hooks — a customer-domain host answers 405. Signature-verified with the store's hook key; x-drop-updated ordering guard applies.
    • POST /hook/{env}/{sid}/data + POST /hook/{env}/{sid}/files[/**] — the legacy per-event ingress, forwarded to receiveHook / receiveFilesHook. Kept alive indefinitely — serves every not-yet-migrated managed entry and is the rollback path.
  • Token model — the two master tokens (one per env) are held only by the directory and used exclusively against /system/tenant/*. Every render, data read, form push and checkout call runs on the store's own {public: true, process: true} token (note CORE - Autogenerated), resolved from the system/{sid} custody in admin-eur3 by the backend.

  • Provisioning is automatic for new stores — the backend's storeSetup trigger provisions on store-doc creation (modules/tenant/tenant_model.ts), and /system/store/delete deprovisions. /system/tenant/provision and the admin card's "Enable web hosting" remain the manual/repair path for pre-existing stores.

  • Publish gate — stays in WebSite: an unpublished store's host answers 404 regardless of mount state.

  • Platform pages — what a visitor sees when NO store page can render, all from drop-core-server's platform_pages.ts (one design — the inmake-website tokens + logo, dark by default, light under prefers-color-scheme, English or Czech per Accept-Language, no external request so the page renders when the store / core / DNS is what failed): unbound (404 — an unknown or reserved subdomain, the apex, an unprovisioned store, TENANT_CUSTOM_DOMAINS_ONLY refusing a subdomain), dns (404 — a custom domain that reaches this server without a valid _drop TXT: the page names the host, says where the record is (the Hosting card — never the record itself, it carries the site key) and the directory's verdict — no_txt / dns_error / env_off / store_unknown / key_mismatch, TenantDirectory.hostFailure(host); the same verdict rides as detail on /-/mount's 404), and error (the catch-all 500, for a request whose Accept names HTML — anything else keeps the {"error":"internal"} JSON). The store-level built-in 404 (a mounted store with no custom notFound doc) is the same module's not_found page, served by WebPreviewServer. All go out no-store + x-robots-tag: noindex, nofollow.