Tenant-specific Workers and Durable Objects
WiseWig managed hosting uses Cloudflare Workers for Platforms as its production tenant boundary. A single production dispatch namespace contains one untrusted user Worker per tenant. Each tenant Worker exports its own TenantWorkspaceDO class, so Cloudflare creates a script-specific SQLite Durable Object namespace rather than placing every tenant in one shared namespace.
This is the Cloudflare implementation of a host-neutral rule: a tenant receives a stable execution identity and a private state namespace. Other hosting adapters may implement that rule differently, but framework code must not depend on a caller-supplied tenant identifier.
Request path
- The dispatch Worker normalizes the request hostname.
- D1 resolves only a verified domain belonging to a non-suspended tenant with a
readytenant Worker record. - The persisted
worker_nameselects the user Worker in the configured dispatch namespace. - The dispatcher removes reserved
x-wisewig-*routing headers and supplies tenant/site context as Cloudflare dispatch arguments. - Cloudflare invokes the tenant Worker with CPU and subrequest limits.
- The tenant Worker verifies the dispatched tenant against its deployment-time tenant identity before selecting its script-specific
TenantWorkspaceDO.
The browser, extension, MCP client, theme, plugin, and request path never choose a Worker or Durable Object name directly.
The public dispatch argument includes an explicit audience: "public". The tenant Worker exposes only a health check and the platform-selected site’s published structured bundle to that audience. Query parameters cannot select another site, mutation methods are rejected, and draft state has no public route.
An explicit __edit=1 or __preview=1 request causes the platform Worker—not the tenant Worker—to verify the signed, revocable session in D1, confirm tenant membership, load authoritative site roles, and require content.view. Only then does it create a bounded private audience containing the platform-selected tenant/site, actor, effective roles, and mode. A missing, revoked, cross-tenant, non-member, or roleless session falls back to the public audience and can never select draft state. Viewers may inspect the draft, but every command and sensitive management query independently checks its exact permission inside the tenant Worker. The tenant Worker exposes private data only at reserved endpoints and always returns private, no-store.
Managed Astro rendering and editor APIs call the trusted tenant dispatch Worker through a service binding. TenantBrowserDispatchClient preserves only the original hostname, session cookie, origin, idempotency key, and confirmation header; it replaces caller-controlled paths and never forwards tenant/site/Worker headers. The dispatcher resolves the hostname and strips the platform session before invoking tenant code. Public and draft bundles, section/variant/structure edits, typed resource saves, theme migration context/switches, and confirmed publication therefore reach the tenant’s script-local Durable Object without binding Astro to any cross-tenant Durable Object namespace.
Managed OAuth start/callback/logout are also fixed dispatcher endpoints, but remain outside tenant code. The dispatcher owns OAuth state, identity mappings, sessions, client/signing secrets, and the verified hostname-to-site decision. Managed SSR does not repeat D1/session work: it requests the hinted mode and renders only the actual published/edit/preview envelope returned by the dispatcher. An unauthorized hint is rewritten to the published-data path before the tenant Worker runs.
Membership, domain, tenant-status, and erasure requests take a deliberately different fixed path. The dispatcher intercepts /_wisewig/platform/* after the same hostname/session/role resolution but before tenant code. It commits those shared-platform effects through D1’s digest-bound command ledger and audit. No tenant Worker, theme, plugin, public route, or public MCP receives the D1 binding or can register a platform endpoint.
The tenant Worker executes browser commands with the same guarded path used by MCP: authoritative roles, edit-only mode, read-only status, per-user quota, schema parsing, optimistic versions, durable idempotency, confirmation, audit, and publish Queue delivery. A theme or plugin receives neither the dispatcher binding nor the platform cookie.
The dispatcher removes the WiseWig session cookie before invoking tenant/theme code and removes any tenant response that attempts to set a WiseWig platform cookie. Other customer-site cookies remain available. This keeps the reusable identity credential on the trusted platform side of the dispatch boundary.
Control-plane state
Migration 0007_tenant_workers.sql adds the stable mapping:
tenant_id: authoritative tenant identity.worker_name: unique Workers for Platforms user-Worker script name.dispatch_namespace: expected namespace for deployment and routing checks.state:provisioning,ready,suspended,failed, ordeleting.release: framework/runtime release deployed to the tenant.errorandupdated_at: bounded operator diagnostics and lifecycle timing.
D1TenantDeploymentStore.begin() is idempotent for the same mapping and refuses to remap a tenant to another Worker. A route is not dispatchable until the operator-owned deployer marks it ready.
Cloudflare topology
Use one untrusted production dispatch namespace and, optionally, a separate staging namespace. Do not create a namespace per tenant. Untrusted mode gives each user Worker an isolated cache and disables caches.default; public page caching therefore remains in the platform-controlled edge/public Worker rather than tenant code.
Every user Worker receives a unique R2 bucket and publish Queue named from its unique Worker identity. Shared resource bindings would allow developer-controlled server code to address another tenant’s objects or enqueue forged cross-tenant work, so tenant prefixes alone are not considered an isolation boundary. The script-specific SQLite Durable Object namespace, R2 bucket, and Queue together form the tenant data plane.
The operator attaches each Queue to the platform-owned tenant dispatch Worker. It refuses a Queue that already has another consumer. On delivery, the dispatch Worker resolves the exact (queue name, tenant ID, site ID) tuple through D1 and dispatches an immutable event audience to the resolved tenant Worker. The tenant Worker must atomically claim a matching publish-operation row for the same tenant, site, operation ID, and published version before it can return a published bundle. Thus code with access to one tenant’s producer binding cannot choose another tenant Worker or manufacture a valid cross-tenant operation.
Public KV indexing, cache invalidation, analytics, and plugin-sandbox invocation stay in the trusted dispatch Worker. The tenant user Worker receives none of those shared bindings. Delivery retries use bounded exponential backoff; the sixth failed attempt records the tenant-local operation as dead and acknowledges the message for explicit operator recovery.
The operator deployer owns Cloudflare API credentials and tenant script lifecycle operations. The request-facing dispatch Worker uses only D1 and its dispatch binding. It cannot create, replace, or delete scripts.
CloudflareWorkersForPlatformsApi builds the required multipart upload with only the tenant identity, framework release, tenant-specific R2/Queue bindings, and a declarative TenantWorkspaceDO export using SQLite storage. The API token exists only in the operator boundary. Before upload, it performs exact-name lookups, idempotently creates missing tenant resources, and creates or verifies the sole platform Worker consumer. It rejects mismatched or foreign provider identities and bounds Cloudflare response bodies before parsing them.
TenantWorkerProvisioner checks the trusted release artifact before creating resources, creates or reuses the tenant resources before uploading code that binds them, and will not mark a route ready until Cloudflare returns the exact expected script identity. Partial resource or upload failures are recorded and remain unroutable. A retry reuses exact existing resources rather than creating shared or alternate resources.
After the Worker is ready, the operator-only site initializer sends one bounded, schema-validated document/theme/resource bundle through an initialize dispatch grant. The tenant Worker validates the page document, theme selection, settings, navigation, forms, redirects, and pages before writing. Exact retries are idempotent; an existing document, theme, or resource that differs returns SITE_INITIALIZATION_CONFLICT instead of overwriting state. Only after the tenant-local bundle succeeds does the deployer create and verify the D1 hostname, owner identity, tenant membership, and site-role mapping. A browser, theme, plugin, or public request cannot invoke initialization.
Failure behavior
- Unknown, unverified, suspended, unprovisioned, or failed tenants return
404 SITE_NOT_FOUNDwithout invoking a user Worker. - A missing script or dispatch runtime failure returns
503 TENANT_WORKER_UNAVAILABLEwithout leaking the script name or provider error. - Both error responses are
private, no-store. - Reserved request and response headers are removed at the dispatch boundary.
Lifecycle operations
TenantWorkerLifecycleManager is an operator-only host-neutral orchestrator:
- deploy, upgrade, and rollback all resolve a trusted immutable release artifact through the same provisioner;
- suspend changes a ready Worker to
suspended, immediately removing it from routing without deleting data; - export produces a content-addressed receipt independently of deletion; and
- restore requires
RESTORE <tenant-id>, an exact persisted Worker/namespace/release identity, a checksum-valid operator archive owned by that same tenant Worker, and an empty destination workspace; and - delete requires the exact phrase
DELETE <tenant-id>, entersdeleting, verifies the persisted Worker/namespace identity, exports, erases tenant-local data, deletes exact Cloudflare resources, and removes the control-plane record last.
Any failure after deleting records a bounded error and leaves a non-routable failed tombstone for operator retry. The Cloudflare deleter lists the exact tenant Queue and consumers before its first destructive call, refuses foreign consumers, force-deletes the exact user script and its script-local Durable Object namespace only after data erasure, then deletes the consumer, Queue, and already-empty R2 bucket. Missing provider resources are accepted during retry, but mismatched resources are never deleted.
Current implementation boundary
The persisted mapping, state transition guard, dispatch Worker, public/private/event/operator audience contracts, revocable-session and authoritative-role grant derivation, platform-cookie filtering, tenant user-Worker template, published/draft-data and publish-delivery bridges, script-specific SQLite Durable Object export and empty-target restore, checksum-verified operator archive copy, script-local erasure, idempotent tenant R2/Queue/consumer creation, tuple-validated operation claims, unique resource-binding metadata, operator upload client, trusted-artifact provisioning flow, stable errors, unit and workerd isolation tests, generated types, Wrangler dry runs, and startup profiles are implemented. Upgrades preserve the previously ready route until the replacement upload succeeds; a failed upgrade records the error without taking the working release offline.
Private MCP reads and mutations resolve the OAuth-pinned tenant/site through D1 and execute inside the tenant user Worker under a permission- and scope-pinned dispatch grant; the gateway no longer binds the shared tenant Durable Object. Managed Astro rendering, core editor commands, page lifecycle, theme switching, public form submission, version history/restore, audit, submission reads, media lifecycle, plugin lifecycle, recovery, and scheduled publishing now use the parallel browser dispatch bridge instead of a shared tenant Durable Object binding. Membership, domain, status, and erasure use the separately intercepted D1 platform-command path. Sensitive operations are fixed by the framework and accept no tenant/site selector. Public form input is same-origin and bounded; Astro forwards only the edge-provided client address, the dispatcher creates the tenant-scoped HMAC abuse subject and strips the address, and only the tenant Worker reads the published form schema and stores the submission. Media bytes stream into the same physically tenant-unique R2 bucket used for tenant-local backup prefixes; opaque public media IDs are resolved against the selected tenant/site SQLite record before R2 is read. Plugin installation requires plugin.manage, active edit mode, same-origin authorization, idempotency, and exact install confirmation before the trusted dispatcher verifies the release signature/digest and calls the isolated sandbox. Theme switching requires theme.switch, a developer-deployed exact release, explicit stable-slot decisions, optimistic page/theme versions, idempotency, and exact confirmation; the migrated page collection and theme pin commit in one tenant-local transaction. Trust keys and the sandbox binding remain absent from Astro and tenant code. The remaining beta isolation gate is the disposable deployed drill.