sites implementation
Project removal
This participant requires core 0.30.11 or later for durable removal acknowledgement and transaction-scoped active-Project admission. Update core before installing this plugin version.
Site/preview insertion, publication identities, conversion admission and Site updates call tx.host.projects.requireActive(id) inside the existing writer-row transaction. Moving or converting a Site locks both source and target Projects in ID order. The core Project row lock lasts through commit and conflicts with deletion admission. Browser capture stays outside SQL; only the final image-file write and metadata update run together under the Project and Sites writer locks in a short, nonretrying READ COMMITTED transaction. File-write failure rejects that transaction; the capture service records its existing visible failure state. No network operation runs under these locks.
The plugin declares provides.projectRemoval: "services". Core invokes registerProjectRemoved while the Project remains present with lifecycle deleting; resolution acknowledges completed cleanup and rejection leaves the participant retryable. The callback fences all Project Sites and conversion attempts, withdraws serving state, retires preview and Site publications through Sandbox, drains admitted preview-image work before deleting release directories, detaches generated/custom hostname bindings and removes durable rows. Conversions into the deleting Project are cancelled without deleting source Sites in other Projects. Deleting Site rows remain discoverable on retry; residual Site/preview rows reject completion. The optional describe callback returns Site titles and hostnames plus previews, with no filesystem paths. Domain DNS records remain externally owned. The former missing-Project boot sweep is removed; core owns replay.
Exact text identity
Ticket and capture-grant issuance/redemption use hashText directly from elowen-plugin-shared/contentHash. The worker's proxy-policy fingerprint hashes its existing ordered JSON tuple through the same helper. Exact UTF-8 bytes produce the same lowercase SHA-256 hex without trimming or normalization, so existing hashes need no migration. HMAC proofs, randomness and access checks remain unchanged. The shipped standalone worker resolves the pure shared module through the host-provided package link, just like its existing atomic persistence helper; it does not load the daemon or a database.
Migration entry
The manifest entry exports migrate(db: PluginDb<never>): Promise<void> beside register.
The daemon loader always awaits it before registration for this reads: ['db'] plugin;
the DB CLI calls the same export without registering tools, routes, timers or services.
The export runs only the existing transactional plugin SQL through db.migrate.
A failed step rolls back its SQL and ledger entry and prevents registration. Repeated
calls skip applied versions. Runtime store construction never invokes migrations.
Tests that bypass the loader explicitly await this export before using their fixture.
The manifest declares the shared helper API used by src/certificate.ts through requiresSharedApi, and the catalog carries the same value. Certificate readiness imports errorText from elowen-plugin-shared/errors to describe failed TLS observations. The host validates the declaration before importing the plugin; a shared API mismatch refuses loading before helper resolution. The existing requiresCore floor also selects a core that ships the required shared subpath.
Authenticated API routing
Sites requires core 0.30.11 and shared API 14. Its authenticated API registers 17 method-specific handlers over 14 complete patterns. Every registration declares its access level and catalog summary. The manifest lists each distinct pattern; the registry count is the number of distinct patterns.
Under /plugins/sites/api/, the routes are:
GET sites,POST ticketandGET directory.GET|PATCH|DELETE site/:siteId.GET site/:siteId/previewandPOST site/:siteId/preview/refresh.POST site/:siteId/members,POST site/:siteId/members/replaceandDELETE site/:siteId/members/:userId.GET|POST site/:siteId/domains,DELETE site/:siteId/domains/:domainId,POST site/:siteId/domains/:domainId/checkandPOST site/:siteId/domains/:domainId/primary.GET gateway/probe, restricted to administrators. All other routes use user access.
Core matches complete patterns and decodes req.params exactly once. Handlers use siteId,
domainId and userId directly; req.path is empty. Missing parameters, malformed escapes,
undeclared suffixes and unregistered methods return 404 before domain work. There is no remainder
dispatcher or authenticated prefix mount. The site wrapper preserves viewer access, manager-only
mutations and domain management, and the deletion retry exception only for DELETE site/:siteId.
Domain service errors retain their coded responses. Public hook routing and the independent worker's
visitor request.path remain separate contracts.
Computer publication transport
The Sandbox control owns one publication contract for managed and computer Projects: projectPublicationAuthorize, projectPublicationBinding and projectPublicationRelease accept { kind: 'managed' | 'local', projectId }. References derive from the active Project's execution kind; host Projects are refused. Core authorizes the publishing account with canExecute, so a computer administrator without membership cannot publish. Binding returns { generation, socketPath } with a daemon-owned Unix socket; computers use generation 1. For either kind, the worker's HTTP and Upgrade proxy path does not change.
Creating or changing a binding supplies the publishing account. Account-free recovery reuses the stored port and, for a computer, rechecks the stored publisher's canExecute. Sandbox owns these durable port, execution-kind and publisher records; Sites records only its cleanup routing identity.
The computer's client announces tunnel support. A typed LOCAL_CLIENT_OUTDATED refusal becomes an instruction to update the Elowen CLI on that computer and reconnect. Offline computers retain their binding; closed socket connections use existing unavailable and reconciliation behavior. Preview requests use the same contract and authority. Sites preserves typed refusals for local_target_forbidden, local_target_unavailable, invalid_port and the current publication_* errors, with actionable account, connection, application or administrator guidance.
Migration 32 records the original transport kind for existing Project ids as managed, the only publication kind accepted before this release. New creation, authorization, binding and preview requests persist the actual kind in publication_projects before creating a transport. These immutable identity records remain cleanup authority after Project metadata is removed or changes; retirement never requires an active Project or guesses its kind. Cancelled conversions and preview deletion use the same stored identity. The records intentionally survive deletion, as core publication retirement fences do.
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 Site and domain text formatting, including certificate wording.
Site and domain tools import textResult directly from elowen-plugin-shared/toolResult for the
single text block and caller-owned details; they infer no status or error classification. 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 visitor-facing 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. Every failed synchronization attempt logs its helper detail locally, including repeated identical failures. Only the third consecutive failure exports its problem code; successful synchronization resets the counter and skipped work leaves it unchanged. Readiness still requires installed worker routing and recent HTTPS/application proofs after cutover.
Gateway synchronization ownership
index.ts::syncGateway owns each complete boot, renewal or healthy minute pass. It first calls
SiteGatewayManager.reconcile, then runs the hostname coordinator. The reconciliation result carries
that attempt's normalized input alongside its status. prepareInput uses the existing publication,
preview and worker preparation path and projects the root binding fields: hostname, slug, class and
worker routing, sorted by hostname, plus every validated proxy setting. Site-local ids and object
property order do not change this comparison.
After the hostname sweep, the orchestrator prepares the current desired input and compares it with
that attempt's input. An unchanged pass makes exactly one root sync-bindings request, including a
pass with no previews. A changed hostname, preview set or proxy policy calls the existing reconcile
path again before preview certificate work. There is no cross-pass cache: each minute still performs
fresh DNS checks and signed worker/application proofs, repairs gateway drift and retries pending
certificate removals. Each sync result remains visible to the monitor, including a refused second sync.
ProjectPreviewService.ensureCertificates(bindings, status, renew) only ensures missing or renewed
certificates over the supplied confirmed snapshot. It never reconciles the gateway. An unavailable or
inactive result skips issuance; failure of the first sync ends the pass and cannot reuse a previous
success. Each preview's current active Project and durable removal state are checked before issuance,
including after awaiting another preview's certificate. Issuance errors propagate to the orchestrator.
Explicit preview requests still use reconcileGeneratedBinding and its existing deletion fence.
Hostname certificate transitions, explicit certificate removal and the two-second publication tick
with the fifteen-second preview retry cadence retain their existing owners.
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.
The publication sweep runs every two seconds, but a remembered transport whose last probe answered healthily
is probed again only when the Site row changes (updatedAt, so a publish, a port or an access change) or
after thirty seconds; a probe that fails or answers 5xx returns the publication to per-tick probing and the
fifteen-second re-establish cadence. Transport failures count actual retry attempts and log a local diagnostic on every failure, including identical messages. The third consecutive
failure reports sites.publication_transport_failed; failures one, two and four onward remain local-only.
A successful operation resets its counter and 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. Both reconciliation catches also
use isInstanceCondition from elowen-plugin-shared/errors before rendering the diagnostic. Native causes
such as a privileged-worker release mismatch remain local without consuming the software-failure counter;
unknown transport and invariant failures still report on the third attempt. Endpoint withdrawal, stored
errors and retry cadence are unchanged. Core derives worker drift from handshake protocol/digest fields,
not the error message.
Daemon and worker gateway synchronization failures report sites.gateway_sync_failed. These transport
and synchronization codes report their redacted message sample, 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 in gateway status remain reportable; this status projection is separate
from the typed publication/preview error path above.
Retry owners use ConsecutiveFailures from elowen-plugin-shared/consecutiveFailures. Publication and preview
transport counters are scoped by publication id; each gateway manager and worker monitor owns its sync counter.
Hostname sweep exceptions use separate custom-hostname and generated-Site keys and report
sites.hostname_reconcile_failed. Site deletion and Project preview cleanup use separate Site and Project
keys and report sites.cleanup_failed. Every producer writes one local diagnostic per failed attempt and exports the code exactly on failure three;
failures one, two and four onward use local-only metadata. Success resets its counter; skipped work does not.
No message-deduplication state suppresses failed attempts. Counters last for this plugin process. Durable
deletion markers and the five-second retry sweep remain active. Intentional shutdown, stopped environments
and application HTTP 5xx responses retain their existing local-only handling and do not count as transport failures.
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.
readServingState, writeServingState, workerBuildId and createSnapshotWorker are asynchronous.
Snapshot publication awaits shared writeJsonAtomicAsync with compact JSON, a trailing newline, a 16 MiB
ceiling and durable writes. File fsync precedes rename and directory fsync; creation also fsyncs the parent.
Reads retain descriptor ownership, mode, link-count, size and O_NOFOLLOW checks. Build fingerprints read
the current module tree asynchronously on each check. Worker control reload awaits validation before replacing
its in-memory projection. Recursive Site cleanup awaits fs/promises.rm before deleting the database row.
Preview image reads, view metadata and notices are awaited by API callers; image publication uses shared
writeTextAtomicAsync and rechecks deletion after writing, before recording image metadata. These paths
perform no synchronous filesystem calls on requests or intervals. Preview reconciliation delegates durable
transport state to Sandbox's projectPublicationBinding; filesystem work behind that boundary belongs to core.
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. 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.
The finite core service-start identity is the immutable workerBuildId(), not
the mutable serving generation. SiteServingService passes its lifetime signal
and a GET-only /control/health verifier through the existing authenticated
service handle; matching build, captured generation and binding count establish
readiness. HTTP 401/403 block; 429/5xx and typed network failures use canonical
retry eligibility and Retry-After minima. No second retry loop wraps control fetch.
POST /control/reload stays a single mutation outside the readiness callback.
Publishing any new desired generation withdraws the previous generation's
confirmation before control dispatch, even if the older worker was ready.
The desired projection and fingerprint stay recorded before dispatch; only a
strict reload acknowledgement or authenticated GET proof of the actual build,
captured generation and binding count restores confirmation. A lost ACK after
application is reconciled by GET only, without repeating reload or allocating
another generation. A reload lost before application leaves access revocation
desired but unconfirmed: the old worker may still serve its previous access state.
Subsequent refresh, ticket and capture-grant operations reject while the desired
generation cannot be proved; daemon publication alone does not prove revocation.
A genuinely newer observed worker generation still advances publication above
it under the existing no-rollback rule; it does not change executable identity
or replenish the core retry episode. Service stop aborts its lifetime. Socket
activation, root supervision and authorization remain unchanged. Source-level
isolated fd3 worker checks do not prove packaged updater or production cutover
acceptance.
The host awaits the entry export migrate(db) before registration reads any Site or registers handlers. SitesStore.create(await ctx.db(), options) constructs the runtime store without applying migrations. All Site, hostname and preview metadata operations return promises. The factory accepts the core SDK's specialized PluginDb; store helpers consume the package's PluginDbSql interface, which is shared by root and transaction handles without requiring a root host accessor. SQL uses positional parameters and the quoted schema selected by the host, with tables such as "plugin_sites".sites; no connection changes its search path. Migration 30 creates the PostgreSQL baseline that installed ledgers already record, so it is never renumbered. Migration 31 inserts the singleton worker_state row with generation zero where it is absent and never overwrites an existing serving generation.
SitesStore.transaction(async store => ...) passes a store bound to the callback's PluginDbTx. Every mutation either acquires the singleton worker_state row with FOR UPDATE, or joins its bound transaction. A missing row fails the transaction with Sites writer state is absent; the store never recreates it. The row serializes writers across daemon and runner connections. It protects hostname limits and claims, first-primary selection, conversion cancellation/commit, membership revocation, image scheduling, snapshot generations and hit replay. The transaction never retries external effects. A failed callback rolls back every composed write and propagates its error. The host invalidates its transaction handle at completion.
hostnameRecords.ts owns domain validation, row mapping and writes on that bound handle. Hostname uniqueness uses a PostgreSQL unique index on lower(hostname), and only that index's SQLSTATE 23505 becomes domain_claimed. Unexpected query failures retain their identity. Site insertion and generated-hostname insertion commit together. Hostless recovery uses the same insertion path and preserves deletion timestamps. Missing hostname configuration does not invent a generated address.
store/previewImages.ts owns image metadata and retains the previous successful image when capture fails. Capture admission checks busy state and the manual refresh interval and records the request in one transaction. Metadata inserts require an existing Site without a deletion marker, so a delayed capture or failure cannot resurrect a deleted preview. File and browser operations remain outside database transactions. Retained release metadata still contains id, siteId, createdAt, model, fileCount, sizeBytes, note and kind, with no image/archive compatibility fields.
Worker projection construction awaits Site, member, hostname, account and Project readers before the existing serialized durable publication boundary. Host reads and address resolution stay outside write transactions so a waiting writer cannot exhaust the pool needed by the lock owner. SiteServingService awaits its projection, secrets and generation before persisting state and requesting a worker reload; ticket and capture exchanges still require the loaded projection. Access decisions await current account, Project and membership readers. Tool ownership uses the host's awaited currentAccountUserId() resolver, and publication tools await currentAccess() before reading the selected Project. Preview recovery awaits that same account boundary before considering background migration through another eligible account. Gateway, session and worker-control tokens await instanceSecrets() before using its asynchronous secret bag. In-memory worker readers use that same decision path. The worker applies the request's admitted proxy-policy timeout before its first asynchronous lookup. Concurrent gateway reconciles return the same pending operation. Active request registration precedes its final asynchronous access check, and reload awaits access revalidation before acknowledging its generation. Application Upgrade checks await revocation and close both sockets on rejection.
Snapshot generation advances transactionally beyond both the stored counter and the observed worker generation. Worker hit counts, the stable batch marker and seven-day marker retention commit together before acknowledgement. Concurrent duplicate deliveries count once; failure leaves counts and marker untouched. Retention keeps an applied_at index. Missing or deleting Sites do not regain hit counts. The publication expiry sweep first checks for a live expired attempt, so an idle tick takes no writer transaction; cancellation repeats the predicate after acquiring the lock.
tests/sites-write-locks.test.mjs exercises independent transaction handles for hostname limits and uniqueness, conversion cancellation, generation allocation, duplicate hit delivery, rollback and acknowledgement ordering. tests/sites-write-boundary.test.mjs checks that mutations use the callback handle and composed writes roll back. tests/sites-schema-retirement-p57.test.mjs pins the PostgreSQL baseline and retained recovery metadata. All database fixtures use the repository's socket-only PostgreSQL harness. Site suites run inside the repository isolation boundary and must never contact a live privileged worker, nginx or gateway.
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 62 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.
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 whose migration_required flag is still set, 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.
Serving and publication boundaries
SitesPreviewImage in web-src/SiteCard.tsx owns the stored image URL and version-specific loading refusal shared by the register card and SiteDetail. Pass site and a caller-owned fallback; the card also passes loading="lazy", decoding="async" and its picture-only status overlay. A missing version or failed image renders the supplied fallback, and a newer version can load again. This component owns no capture requests, polling, monogram text or refresh controls.
SitesDetailRail in web-src/SiteDetail.tsx composes the host WorkspaceDetailRail and SiteDetail for SitesPage and SitesProjectPanel. Pass siteId, allowPublicSites, busy, onClose, onDeleted and onBusyChange. Labels come from the Sites plugin strings; close is ignored while busy. Each caller owns selection and list filtering and mounts no rail without a selected site.
registerSitesUi(pages, project) registers only the Sites pages and Project panel on the existing host UI API. Sites contributes no settings surface and no event mutation grant. Host settings contributions for other plugins remain supported. The real web-src/index.tsx is its consumer.
SitesSandboxControl is a Pick of core SandboxControl's publication authorize, binding and release methods; Browser capture uses core BrowserCaptureControl directly. requestPublicationBinding(control, input, missingSocketMessage) calls the durable transport once and rejects an absent or empty socket before adoption. It retains the complete core binding result and caller-owned failure text. The publication and preview services keep their own probing, retry, endpoint and account-migration lifecycle. A missing Sandbox still follows requireSandbox; a missing Browser is a capture availability outcome. isActiveManagedProject shares only execution-kind and lifecycle admission, with account identity and membership authorized separately.
reconcileGeneratedBinding reconciles the desired binding set, requires an available active gateway, runs the caller's deletion fence and then ensures the generated binding. ProjectPreviewService uses the fence to re-read its durable removal state between activation and issuance. Generated certificate sweeps reuse the same sequence. Custom-domain configuration and issuance remain separate state transitions. isAsciiHostnameLabel owns canonical ASCII label syntax only; appHostname canonicalizes a parsed URL's host. Public hostname parsing, reserved-host admission and deployment HTTPS policy remain separate, with candidate core parity tests.
ServeDeps receives an explicit bindingForRequest callback and urlForHostname. The worker callback uses findSiteAddressBinding over its persisted bindings; SiteAddressService does not expose a request matcher. Proxy and worker share rawResponseHead for HTTP Upgrade success and refusal, preserving status, reason phrase and repeated headers. Site session cookies share attribute construction with an explicit secure value; capture cookies remain Secure, while account sessions follow the site's HTTPS origin. These helpers add no transport or authorization authority. Ticket and capture grants retain separate admission and replay rules.
The Sites API exposes sites, site, ticket, directory and the admin gateway/probe endpoint. Project previews are requested through SitePreview; the former preview POST and gateway/readiness GET are not registered and return the host's ordinary missing-route response. SiteView omits basePath, createdAt, createdModel and currentReleaseId. HTTP archive release entries retain siteId and omit constant kind; SiteGet release details also omit constant kind. Historical release kind and environment-snapshot filtering remain storage/recovery concerns. HTTP sourceDir and structured SiteGet sourceDir remain available in this change. The domain-list response contains only generated address information and the domain records; unread siteId, effectiveUrl and primaryHostnameId projections are removed from the service, HTTP response and UI mirror. Per-domain isPrimary reflects the persisted primaryCustomHostnameId, and the stored Site and tool diagnostics retain their live authority. The register consumes GET /plugins/sites/api/sites and reads current visibility, publication status, URL, preview state and manager permissions.
Capture availability resolves one Browser control and one published address per check, returning either both values or a refusal reason. An attempt uses that resolved pair and records unavailability through the existing failure/backoff row instead of leaving a pending request silently. request(siteId, cause) still applies the manual refresh limiter; the cause is not persisted. The previews table keeps no image bytes and no requester column; requested_at, version, MIME, dimensions, captured_at, failure and retry fields remain durable. markPreviewImageRequested(siteId, now) records scheduling; storePreviewImage(siteId, image, now) returns void and atomically advances the cache version after filesystem publication. SitePreviewImageService is the production writer, and the register fetches that versioned image.
SiteServingMonitor shares generation/time checks, the 90-second freshness bound and final per-binding readiness. inspect reports immediate worker/gateway state; readiness and notices retain the 180-second sustained-failure hold. Gateway mutation remains admitted only after recover validates the worker and fresh application checks succeed. Missing or stale checks do not authorize mutation. A maintenance address preserves its archive and recovery identity; it does not serve retained files. Source display recovery remains separate from the project summary projection, which contains slug and execution kind only.
ApiDeps requires previewSite, previewImages and sourceDisplayPath; ToolDeps requires previews and previewImages. The Sites entry point always supplies ProjectPreviewService, SitePreviewImageService and the archive source-display projection before registering routes or tools. Missing Browser capture remains an outcome reported by SitePreviewImageService, not an absent Sites service: lists and archive recovery remain available, manual refresh returns the service's refusal, and SitePublish returns a successful publication with an explicit preview refusal. Only manual and publish are request causes; ensureFresh schedules register freshness separately. HTTP archive release entries retain siteId and omit the constant kind. Missing composition dependencies are type errors, not optional behavior or synthetic HTTP defaults.