NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · sites implementation
Developer reference

sites implementation

Tool implementation contract

The plugin entry calls src/tools.ts::registerTools with one ToolDeps from src/tools/types.ts. That entry preserves the ordered registration of preview, creation, publication, listing and detail, followed by the four domain tools and then update, sharing and deletion. src/tools/site.ts owns the Site registrations; src/tools/domains.ts owns custom-domain registrations and their service-error translation. No tool name, schema or host permission changes at this boundary.

src/tools/resolve.ts owns account, Site, domain and person resolution, the in-process Project projection and the single ToolError identity used by both families. Owner-only mutations keep their owner check; detail and domain operations retain canManage for owners and administrators. Missing, unauthorized and deleting Sites still throw the existing refusal instead of returning a successful text answer. Project lookups read the host register without a container request. src/tools/render.ts owns shared text formatting and the result envelope, including certificate wording. SiteGet, for example, uses the same Project projection and Site description as SiteList and SiteUpdate; readiness still performs its existing single-site certificate observation.

Deletion and resource ownership

Deleting a Site first records a durable deletion marker, revokes database access state and withdraws its persisted worker projection before stopping its Project publication forwarder. The worker reload removes the Site, endpoints, members and hostname bindings, pending tickets and capture grants, spent-token records, and both buffered and unacknowledged visit counts. Shared plugin session/gateway/control secrets are instance-wide and remain for other Sites; there is no per-Site signing key in secrets.json. Hostname ownership tokens are deleted with their database rows.

Cleanup retires the publication identity before removing its forwarder socket and serving record. Sandbox keeps a non-serving identity fence so a late request cannot recreate a deleted transport. Cleanup also removes retained release files and the Site directory, generated/custom hostname rows, per-Site access/capture/visit/release records and finally the Site row. Failures preserve the deleting or hostname-removal marker; boot and the existing five-second deletion sweep retry. Removing only a custom domain withdraws its binding, hostname ownership token and owned certificate, but preserves the Site and its publication transport. Neither operation changes the Project application, source files or DNS provider records.

The matching core helper records an explicit per-hostname removal intent before probes or effects, then removes nginx references, including maintenance for the deleted hostname, before deleting only its ledger-owned certificate. The Site/domain database marker already exists before the request. A shorter or empty binding list is not removal authority: transient DNS/certificate filtering preserves the existing certificate and keeps its stored hostname in maintenance, without reissuance. Successful binding sync retries pending explicit removal, including interrupted Certbot deletion; the healthy-worker sweep calls it about once a minute. Completed tombstones suppress stale re-inclusion until an explicit, probed issuance request recreates that hostname. Historical owned orphans without removal intent remain for explicit operator cleanup.

Legacy adoption requires the exact single-host helper webroot, production ACME server and live/archive paths in a protected renewal configuration parsed by Certbot's ConfigObj parser. Before adoption and Certbot deletion, every physical ancestor and directory must be protected root-owned and non-symlink; only live PEM links into the same hostname's protected helper archive are accepted. A matching name, old host webroot or textually valid configuration over a redirected directory is insufficient. Host-owned certificates are preserved and are not renewed or claimed by Sites. Explicit offline/uninstall is different: it retains bindings and HTTPS maintenance certificates instead of deleting Sites.

Worker-only serving contract

The manifest declares service: {entry: 'dist/worker/main.js'} and liveConfig: true. There is no public daemon Sites route or alternate ticket backend. Registration prepares durable state, adopts proxy and preview publication sockets, loads the complete projection, then requests probe-gated root cutover. Failure preserves the actual installed upstream and refuses new one-use access exchanges until root confirms worker routing. Proxies and previews require application-backed signed proofs; maintenance proves the worker without an application. Production systemd and deployment verification remain operator gates; branch tests do not perform cutover.

Recovery and admin health

The worker checks every binding each minute using verified HTTPS to 127.0.0.1:443 with the real Host and SNI, a fresh signed challenge, its own process nonce, serving generation and policy. It separately probes the Project socket with the same Host. Both requests have absolute two-second budgets; eight concurrent binding checks share a ten-second sweep deadline. Unchecked bindings fail, never default to success. Each result keeps the first continuous failure time and is exposed in authenticated health.

The daemon reads fresh root status and worker control state every minute, restarts an inactive, unreachable or stale independent process through the existing core service handle and refuses gateway repair on invalid or stale observations. Current, application-healthy state may repair nginx; root repeats signed data-plane proofs before mutation. Worker, gateway and per-host conditions raise admin-only alerts and clear on recovery. Alert writes are awaited. Readiness reads current authorities, not cached success. Admin GET /plugins/sites/api/gateway/probe exposes the same fresh read-only result and is indexed in the marketplace catalog. The approved manifest grants the core alerts capability. Paired registration tests use real core modules/dependencies even under the registry's unit-test loader; plugin-only unit tests retain their scoped substitutes. Boot cutover uses direct socket/application proofs, not the post-cutover TLS loop, to avoid a bootstrap cycle. Per-host HTTPS alerts apply only to a fresh installed binding explicitly routed to the worker; daemon-routed bindings clear these alerts. An unknown routing authority does not clear an existing host alert. The synchronization result stays visible to the monitor, including a refused proof, rather than being overwritten by a successful status read of the retained daemon gateway. Each distinct synchronization failure is logged once per continuous failure episode with the helper detail; successful synchronization resets the episode. Readiness still requires installed worker routing and recent HTTPS/application proofs after cutover.

Problem reporting

Publication and preview reconciliation each share one pending sweep across concurrent callers, including gateway preparation and interval ticks. The plugin enforces this independently of core interval scheduling. Transport failures report sites.publication_transport_failed only when the message changes; recovery clears the remembered failure. Publications retain their unhealthy status and local-only warnings for application HTTP 5xx responses. Sandbox's typed environment_stopped refusal is local INFO for both publications and previews, with the publication's stored error retained.

Daemon and worker gateway synchronization failures report sites.gateway_sync_failed. These transport and synchronization codes are code-only, keeping helper detail in local diagnostics. Intentional privileged-worker shutdown exposes code: 'closed' in gateway status. Gateway status remains unavailable; the monitor skips that observation without creating or clearing outage notices. Connection failures, timeouts and worker drift remain reportable.

Site deletion reports sites.cleanup_failed once per Site until deletion completes. Project preview cleanup reports once per Project until cleanup completes. The durable markers and five-second retry sweep remain active; warning memory lasts for this plugin process.

Worker outages held for three minutes use the existing ctx.alerts problem category worker-unavailable, reported as alert.worker_unavailable for plugin sites. Core's alert transition handling reports once per outage and reopens reporting after recovery. Gateway, hostname and retirement notices remain local-only. Optional thumbnail capture retries stay informational. No core seam or new problem code is introduced.

Independent proxy worker contract

Project responses and uploads remain native Node streams. The worker sends responses with stream.pipeline into its Node HTTP response and aborts upstream on visitor disconnect; signed application probes destroy their Node response after checking headers. Upload pipelines use a bounded Node bridge so teardown cannot destroy the visitor's shared response socket before an early application answer or a readable size refusal. No Node/web stream round-trip enters this data plane. Preview streams recheck access after upstream headers and before each consumed chunk. On valid access reload, active HTTP and Upgrade sockets whose authority was revoked are closed, even when an upstream is idle. Unknown process-level exceptions retain Node's fatal exit; ordinary client aborts remain request-local.

The worker-only data plane has an independent entry at dist/worker/main.js. It consumes exactly one systemd listener on descriptor 3, verifies LISTEN_PID and LISTEN_FDS, and reads only ELOWEN_PLUGIN_DATA/service/snapshot.json and secrets.json. Its --check preflight validates those files without binding or importing the daemon or a database. The import-graph contract test uses the TypeScript parser, not a text search. A retained-listener test exercises real Unix descriptor inheritance, a replacement process and a queued request; it does not start host systemd.

Snapshot schema 1 holds proxy Sites, ownerless Project previews and maintenance Sites; trusted Project publication socket endpoints; active hostname bindings; member ids; account/admin/Project-access projections; serving configuration; and the complete global proxy policy. Maintenance has no endpoint. Owner zero is accepted only for the constrained Project-preview shape, without named guests. Static publication entries are refused. Secrets schema 1 holds the existing session signing secret, gateway marker and separate control token. State files are atomically replaced with exclusive random temporary names, mode 0600, fsync, rename and parent fsync inside an owner-only 0700 directory. Readers reject redirected or unsafe files, oversized files, malformed schemas, duplicate identities and cross-Site bindings.

A worker retains the last valid access projection during a daemon outage. Existing private sessions therefore continue until session expiry under that frozen projection. A successful reload applies current access, bindings, endpoints and proxy settings without restarting the process. Invalid reloads, generation rollback, changing a generation in place or replacing secrets fail explicitly while preserving the prior serving state. Secret rotation requires a deliberate service restart.

Graceful nginx reloads stamp each proxy request with a derived x-elowen-site-policy fingerprint. The snapshot retains up to 128 distinct validated policies so requests from retiring nginx workers retain their original inactivity/Upgrade/body/response rules, including after a worker replacement. The marker is stripped before application forwarding. Unknown policies fail closed. At the history bound, the new synchronous core validation seam rejects a previously unseen policy before configuration persistence. Existing desired-policy disagreement does not block access, account, endpoint or binding refresh: these security changes persist under the last applicable policy, and gateway application reports failure instead of pretending the new policy applied. The bound is not compacted automatically; an old long-running nginx process may still use every retained policy.

The private control channel accepts the separate x-elowen-control-token proof, checked in constant time before reading a body. It offers GET /control/health and POST /control/reload, tickets, capture-grants, hits-drain and hits-ack. Control bodies and access-exchange bodies share worker/server.ts's smallControlBody reader with a fixed 64 KiB byte ceiling and their existing caller-specific limit errors. The runtime parses private control JSON only after that read; visitor application uploads remain streams governed by the separate proxy policy. Tickets and grants expire within the existing minute plus five seconds of clock tolerance and are consumed once. Capture grants are generation-bound; ticket redemption rechecks current access before minting a session under the current generation. An in-memory spent-token ledger prevents reinsertion before expiry and bounds outstanding access exchanges at 20000. Ephemeral exchanges are not restored after worker replacement.

Hit drains return a stable batch id and UTC-day counts until acknowledged, retaining subsequent visits separately. The daemon must apply batches idempotently before acknowledgement. A reserved /s/<slug>/__elowen/probe requires the gateway marker, exact active Host/slug, a 16-byte hexadecimal nonce and x-elowen-site-probe-auth: HMAC-SHA256(gatewayToken, JSON.stringify([hostname, slug, nonce])). The gateway marker alone never authorizes a visitor's probe. Its response signs [hostname, slug, nonce, snapshotGeneration, workerNonce, proxyPolicyHash] under the gateway secret. worker/protocol.ts owns both ordered HMAC tuples through probeAuthToken and probeProof; the runtime and HTTPS self-check use these functions with the unchanged UTF-8 gateway-token key and JSON serialization. The random worker nonce changes per process and is also reported by protected health, alongside schema and the runtime-module build hash, so an application reply cannot impersonate a direct worker-binding probe or a stale process. The policy hash uses a fixed ordered tuple of all eight validated proxy values, letting root reject a worker still applying the previous policy before nginx changes.

Before the first authoritative registration, worker --check returns Unix EX_TEMPFAIL exit 75 only when the complete serving directory is absent. Paired core 0.29.18 bulk convergence explicitly defers that uninstalled service without creating units or claiming readiness; existing, partial or corrupt state still fails. No core or database module is imported by the worker. Registration first calls SiteServingService.prepare: preserve validated existing state and matching secrets, or seed an empty projection only for a new inactive service. Background reconciliation adopts both Project publications and previews before loading the complete worker projection and requesting root binding reconciliation. Every desired binding selects the worker. Tickets and capture grants go only to authenticated worker controls after fresh root status proves worker routing and the current projection has loaded. Unknown, inactive or legacy routing refuses the exchange.

The daemon-owned SiteServingService serializes projection writes, obtains a fresh active-worker generation before reattachment, writes state before an inactive service's root preflight, reloads changed projections without restarting an active worker, and pushes access exchanges only after the projection has loaded. Its private control client has a five-second deadline, bounded command/response sizes and typed response parsers; none of these limits apply to visitor requests.

SitesStore remains the shared database facade for publication, domain and preview services. Its constructor passes the configured hostname base to store/migrations.ts, whose ordered schema 1–27 bodies are immutable. hostnameRecords.ts owns domain types, row validation and writes, using the same database handle, injected clock and claim generators; domains.ts imports its claim and primary-hostname error classes directly so each has one runtime identity. Site creation and hostless boot recovery share one generated-domain insert, with recovery carrying a deleting Site's removal timestamp. Without a hostname base neither path creates a generated row. store/previewImages.ts owns picture types and metadata and keeps the last successful image when capture fails. These owners use the facade's existing transactions and add no background work.

Sites schema 25 retires product file serving, preserves original recovery references, adds retryable preview removal markers and drops SQLite ticket/grant tables. Schema 26 adds selected publication identities, the expiring conversion journal and a transport-migration marker only for pre-existing previews. New preview requests are preauthorized before persistence, never retried under a different background account, and need an explicit authorized retry if their first transport creation did not finish. Preview deletion hides its record before worker withdrawal, gateway/certificate removal and durable publication release; failed cleanup keeps the marker for boot and periodic retry. The existing monotonic snapshot-generation counter and hit-batch deduplication ledger are retained. The generation counter is transactional and advances beyond an observed running worker even after a daemon restart or clock change. Hit counts and the applied batch marker commit in one transaction before acknowledgement. A failed transaction leaves both untouched; a lost acknowledgement permits a retry without counting twice. Late counts for missing or deleting Sites are discarded, preserving the existing immediate deletion semantics. Empty batches are acknowledged without storing a ledger row.

Sites schema 27 retires the unused environment action, exec lease, runtime migration and runtime record tables, the four environment resource/state columns and the release image/archive metadata columns. Constructing SitesStore runs the historical migrations and then this retirement through PluginDb.migrate, atomically with the version record. No operator SQL or separate cleanup job is required. When Sites does not initialize, this migration does not run. Fresh installations reach the same schema as upgrades. The migration removes only the retired database data; it preserves Site identities, file recovery references, release rows/files, hostname state, publication attempts and the worker generation/replay ledgers. SQLite column removal performs table rewrites during initialization, with no added polling or background work. Site deletion uses the resulting schema and still requires hostname cleanup before deleting the Site row. The registered Sites plugin is the production consumer.

SitesStore.releases(siteId) returns retained release metadata without imageRef or dataArchive. Consumers use the existing id, siteId, createdAt, model, fileCount, sizeBytes, note and kind fields; an empty history returns an empty array. There are no replacement fields or compatibility defaults. SiteGet and the Site detail API consume this history and continue filtering environment-snapshot entries by kind. Install the schema migration together with the matching store code; older code that deletes retired runtime tables cannot operate against schema 27.

The gateway readiness row and admin bell share the monitor's three-minute serving-condition hold. The monitor uses each binding's existing firstFailureAt, the worker check's 90-second freshness deadline for stale observations, and the first observation for gateway inactivity or invalid/missing worker state. Recovery resets the corresponding condition. Core's private notice clock is not exposed to plugins, so Sites raises already-held notices without holdMs and supplies their since parameter itself. Host notices remain suppressed before worker routing is installed. Readiness lists failed hostnames in deterministic order, or names stale worker checks and inactive/unavailable gateways, with details bounded to 320 characters. These server-generated readiness details follow the existing English-only convention; bell templates remain localized through manifest and Czech/Slovak overrides. DNS setup failures and exceptions obtaining fresh serving state still report immediately because they cannot establish a valid serving observation. The admin gateway/probe response remains the raw instantaneous proof, without the hold.

The manifest activates these components through the authoritative plugin registration path. Worker cutover requires the worker's signed application-backed proof for every binding; refusal preserves the last nginx generation. The integration tests exercise a real registered recovery interval through the core admin-alert capability as well as boot, live policy and one-use sign-in.

Developer runtime contracts

Gateway shutdown status

PublishedSitesGatewayStatus.code is optionally 'closed' when the privileged worker client intentionally shuts down, including daemon pause. SitesGatewayStatus preserves this field. SiteGatewayManager skips the corresponding sync warning; SiteServingMonitor returns without changing its held notices. Other failures omit the code and retain their diagnostics. Consumers must not classify shutdown from detail text. This is a status signal, not a retry policy or proof that a site is healthy. The Sites serving monitor is the production consumer.

Sites host formatting and requests

Sites requires UI API 55 and a containing host artifact exposing callable hooks.useNow. SiteDetail obtains the viewer locale from hooks.useTranslation and renders release sizes with utils.formatBytes(bytes, locale). The host owns the binary 1024 ladder, B/KB/MB/GB/TB units, precision and decimal localization; no plugin formatter is provided. Text templates use utils.interpolate(template, values), preserving unknown placeholders and literal replacement values. JSON writes use api(path, { method, json: payload }); serialization, errors and authentication remain host-owned. Raw requests retain the host request contract. Missing required runtime members are unsupported, with no local formatting, clock or serializer fallback. SiteDetail release captions and SiteDomains domain creation are real consumers.

Visible domain setup clock

SiteDomains subscribes to hooks.useNow(1000, watching) only while an active, unfinished domain setup is open. One nextCheckAt schedule drives both the countdown and automatic checks. Hidden tabs do not start automatic checks; an existing request may settle. The host clock refreshes on return and an overdue check runs once when no request is pending, then schedules the next check twenty seconds later. Opening or changing the setup identity initializes the schedule; closing it or reaching a ready domain disables the clock subscription. There is no local timer fallback, no replay of every elapsed interval, and no change to the server's background domain schedule.

Next: Skills Plugin

Certificate retry and preview admission

A refused generated certificate attempt commits its refusal detail, cleared request, failure count and next attempt time in one store transaction. Publication and the periodic coordinator use this same write; the coordinator does not record the failure twice. certificateRetryAt owns the certificate schedule for both generated and custom hostnames: one minute, five minutes, fifteen minutes, then at most one attempt per hour. The stored count and next attempt time survive reloads. Custom domains keep distinct gateway-configuration and authority-refusal codes.

All HTTP and Upgrade visitor traffic, including Project previews, uses the independent worker. Preview access is current Project membership or administration, never a stored owner grant. Initial creation uses the current authorized writable account through Sandbox's durable projectPublicationBinding; an existing record reattaches without an account. The preview id is the publication id. Before any new preview intent is persisted, Sandbox preflight checks the current writable account. During upgrade, background reconciliation can create a missing durable preview record only for rows marked by migration 26, without an ambient actor and through Sandbox's full authorization. It tries current eligible accounts until one has Sandbox access, skips only explicit account or Project permission refusals, and reports runtime failures without trying to evade them. Each failed preview is logged and retried independently. Preparing a preview rechecks its durable removal marker after the transport and each gateway await, so deletion cannot return a removed address as a successful result. A failed forwarding request does not delete its durable transport.

The plugin validates numeric and boolean values; the core-owned root helper independently rejects wrong types, out-of-range values, extra keys and arbitrary fragments or paths before rendering nginx. The worker must receive the same policy in its snapshot before worker routing is enabled. Gzip and HSTS are nginx-owned, not a second worker transformation. Disabling HSTS does not undo a policy already remembered by a visitor's browser.