Contents · Extend

Docs / Extend

Hosting seams

This guide covers the policy hooks a host passes to createApp({ host }) to run EdgeCMS as a multi-customer service: who may create tenants, tenant states and lifecycle events, sign-up and MFA rules, usage metering, quotas and per-tenant rate limits. It is for developers composing their own Worker entry around edgecms-api. Background is in PRD.md §10.1.

Each app instance keeps its own copy of the policies, and request input never changes them. Omitted hooks keep the framework defaults.

ts
import { createApp, type HostPolicies } from 'edgecms-api/app'

const host: HostPolicies = {
  // Who may create a tenant. Return a boolean or a decision object.
  canCreateTenant: async (actor) =>
    actor.isSuperAdmin || { allowed: true, initialStatus: 'pending' },
  // Awaited after a tenant lifecycle change commits. Keep it fast; errors are logged.
  onTenantEvent: async (event) => {
    console.log(event.event, event.tenant.slug)
  },
  // Sign-up policy, email sender and super-admin MFA.
  auth: {
    signupPolicy: 'invite-only',
    emailSender: { send: async ({ to, subject, text }) => myMailer.send(to, subject, text) },
    requireMfaForSuperAdmins: true,
  },
  // Per-tenant usage, delivered after the response through waitUntil.
  usageSink: { record: (events) => usageQueue.sendBatch(events.map((body) => ({ body }))) },
  // Append-only audit events for a SIEM/log pipeline (hookable webhook/batch; not a full connector).
  // Prefer createWebhookAuditSink({ url, secret, batchSize }) or AUDIT_SIEM_WEBHOOK_URL in main.ts.
  auditSink: { record: (event) => auditQueue.send({ body: event }) },
  // Optional change/config evidence sink (ISO 27001 A.8.9 / A.8.32 engineering evidence — not an ISMS).
  changeControlSink: { record: (event) => changeQueue.send({ body: event }) },
  // GDPR / PDPA: residency notice + listener after HS-13 export/delete (does not pin CF regions).
  privacy: {
    dataResidencyNotice: 'Operating entity MY; Cloudflare global edge; EU transfers under SCCs.',
    onPrivacyEvent: (event) => privacyQueue.send({ body: event }),
  },
  // Opt-in public delivery ABAC (off by default). Host grants ≠ user RBAC rows.
  // Denied content reads return ABAC_DENIED + x-edgecms-policy-receipt; assets/GraphQL skipped.
  publicAbac: {
    enabled: true,
    grants: [{ locale: ['en', 'fr'], channel: ['published'] }],
  },
  // Quotas. Return true to allow, or a denial with an upgrade link.
  limits: {
    check: async ({ dimension, tenant }) =>
      dimension === 'entries' && tenant && (await entriesLeft(tenant.id)) <= 0
        ? { allowed: false, upgradeUrl: 'https://example.com/billing', period: '2026-10' }
        : true,
  },
  // Per-tenant rate limits. Return nothing to keep the framework policy.
  rateLimits: ({ tenantSlug, category, defaultPolicy }) =>
    category === 'public_read' ? { ...defaultPolicy, tenantLimit: 1_000 } : undefined,
}

export const app = createApp({ host })

usageQueue, auditQueue, changeQueue and entriesLeft stand for the host's own Queue bindings and plan lookup.

When changeControlSink is set, createApp emits one runtime_config / host_policies_applied boot event. Hosts may also call notifyChangeControl for reviewed code deploys, emergency changes, or access-policy updates. See ISO 27001 Annex A evidence.

Tenant creation

canCreateTenant(actor) is asked before POST /api/admin/tenants and POST /api/bootstrap/provision-tenant. actor.source is admin-api or onboarding.

  • Default: super-admins only on the admin API. Onboarding still gives a signed-in user with no tenant their first workspace.
  • Return false, { allowed: false, reason }, or throw to deny with 403 FORBIDDEN.
  • Return { allowed: true, initialStatus: 'pending' } to create the tenant as pending.
  • A creator who is not a super-admin becomes the tenant owner. The membership is written in the same D1 batch as the tenant.

Tenant states

StatusAdmin, MCP, AI, syncPublic REST reads and GraphQLSuper-admin
activeAllowed (RBAC applies)AllowedAllowed
suspended403 TENANT_SUSPENDEDAllowedAllowed
pending403 TENANT_UNAVAILABLE403 TENANT_UNAVAILABLEAllowed
disabled403 TENANT_UNAVAILABLE403 TENANT_UNAVAILABLEAllowed

The legacy inactive status, and any status not listed above, behaves like disabled. Draft preview tokens (/public/preview-token) are admin work, so a suspended tenant cannot issue them. Responses that a CDN or browser cached before a status change stay cached until their TTL ends.

Tenant lifecycle events

EventWhen
tenant.createdA tenant is created through the admin API or onboarding.
tenant.activatedStatus changes to active from any other status.
tenant.suspendedStatus changes to suspended.
tenant.deletedA tenant is deleted.
tenant.limit_reachedA limit denied a request. Sent once per tenant, dimension and period.

Events go to host.onTenantEvent and to the webhook event bus. Both are awaited inside the request, so a slow listener slows tenant changes. Enqueue the work instead of calling slow APIs. Webhook delivery uses global scope: only super-admins can subscribe a global webhook (created on /api/admin/webhooks without a tenant) to tenant.* events, because those events describe every tenant. A tenant-scoped webhook cannot subscribe to them.

tenant.limit_reached carries limit: { dimension, kind, limit?, used?, upgradeUrl?, period }. The period is the denial's period, or the UTC day when the host gives none. A KV marker suppresses repeats across isolates, but two isolates that race can each send one, so treat delivery as at least once. A denied tenant creation has no tenant yet and sends no event.

Sign-up and email verification

host.auth.signupPolicy (or the SIGNUP_POLICY var) is open, invite-only or disabled. The default is open so existing deployments keep working; use disabled for new ones.

  • Every account creation path is checked, including Microsoft and Google sign-in. The first account of an empty database is always allowed, so a fresh install can be bootstrapped.
  • invite-only accepts a new account only with an unused, unexpired invite for that email. Super-admins create invites with POST /api/admin/invites (email, optional tenantSlug and tenantRole, expiresInHours); the token is returned once, only its SHA-256 hash is stored, and it can be revoked with DELETE /api/admin/invites/:id. The admin sign-up page reads ?invite=<token>.
  • With host.auth.emailSender (or AUTH_EMAIL_SENDER=console in development), email/password accounts must verify their email before they can sign in. EdgeCMS ships no email provider.
  • Super-admin by SUPER_ADMIN_EMAILS requires a verified email. A listed address can be registered only when verification is enforced (an email sender is set) or the identity provider verified it.
  • Unlike canCreateTenant and onTenantEvent, auth policies apply to the whole Worker isolate.

Multi-factor authentication

Users enrol an authenticator app with recovery codes under Settings → Profile; sign-in then asks for a code.

  • host.auth.requireMfaForSuperAdmins (or SUPER_ADMIN_REQUIRE_MFA=true) blocks super-admins on admin routes until they sign in with a second factor.
  • requireMfa: true on PUT /api/admin/tenants/:slug does the same for everyone on that tenant's admin and MCP routes. Public reads are not affected.
  • Blocked requests get 403 MFA_REQUIRED. Access tokens are not minted for such sessions, and changing a tenant's setting invalidates its existing tokens.
  • Microsoft and Google sign-ins get no EdgeCMS second-factor challenge, so they do not satisfy an MFA requirement.

Usage metering

host.usageSink.record(events) receives batches of UsageEvent:

{ tenantId, tenantSlug?, metric, quantity, detail?, timestamp }
MetricQuantity
public_reads1 per successful public REST or GraphQL read the Worker served. Reads answered by the CDN never reach the Worker and are not counted.
admin_mutations1 per executed command (a transaction counts once). Dry runs, failed commands and idempotent replays are not counted.
ai_tokensInput plus output tokens of one model call; detail is the model tier (nano, mid, heavy).
media_bytesSigned change in stored bytes (original plus image variants): + on upload, - when an asset is deleted. Soft-deleted assets stop counting even though R2 keeps them until garbage collection.
entriesSigned change in entry count: +1 per createEntry, -1 per deleteEntry, including inside transactions.

Commands sent through the admin API, MCP and AI routes are metered. Imports and scheduled publishing are not. Global (non-tenant) routes are not metered, because there is no tenant to attribute them to.

Recording never fails or slows a request. The framework hands each batch to waitUntil after the handler. A sink that throws, rejects or hangs is logged and dropped. Keep record cheap: enqueue to a Queue or buffer, then return.

D1 daily rollups

createD1UsageRollupSink({ db: env.DB }) sums events into usage_daily (tenant, UTC day, metric, detail). Migration 0035_usage_daily.sql creates the table; apply it with the other migrations. The sink buffers in the isolate. When an event arrives at least 10 s (flushIntervalMs) after the last write, or once 50 rows are pending (maxBufferedRows), it writes the buffer in one D1 batch. There is no timer, so rows wait in the buffer until the next event arrives; call flush() yourself if you need them written sooner. A failed flush keeps its rows for the next one. Counts still in the buffer when an isolate is evicted are lost, so treat these rollups as approximate. For billing-grade counts, send events to a Queue and aggregate them there.

Limit policy

host.limits.check({ dimension, amount, tenant?, actor? }) runs before:

DimensionChecked beforeamount
commandsEvery command (admin API, MCP, AI execution) that is not a dry run1
entriescreateEntry, including inside a transactionNumber of entries created
media_bytesPOST /admin/assets/uploadUpload size in bytes (original plus variants)
ai_tokensPOST /admin/ai/command (except when executing a preview receipt) and /admin/ai/workflow0: the cost is unknown before the call, so deny when the tenant has no budget left
tenantsPOST /api/admin/tenants and onboarding, after canCreateTenant allows the actor1 (tenant is absent)

tenant includes the tenant-row media limits (mediaUploadMaxBytes, mediaUploadMaxDimension). The upload path keeps enforcing those per-file limits itself (413, 415) whether or not a host is configured.

Return true or { allowed: true } to allow. Return false or { allowed: false, kind, message?, limit?, used?, upgradeUrl?, retryAfterSeconds?, period? } to deny:

kindResponse
quota (default)402 with code QUOTA_EXCEEDED
rate429 with code RATE_LIMITED and a Retry-After header when retryAfterSeconds is set
json
{
  "success": false,
  "error": {
    "code": "QUOTA_EXCEEDED",
    "message": "Quota exceeded for entries",
    "details": { "dimension": "entries", "kind": "quota", "limit": 500, "used": 500, "upgradeUrl": "https://example.com/billing" }
  }
}

upgradeUrl must be an absolute http or https URL; any other value is dropped. The admin shows these errors in a toast, with an Upgrade button when a link is present. A provider that throws allows the request (fail open) and logs host_limits_check_failed. Without host.limits, every check passes and nothing changes. Global routes are not limited.

Per-tenant rate limits

Every API request except health checks and /api/docs is rate limited per caller (user, else IP) and route, in one of these categories:

CategoryRoutesFramework policy
auth/api/auth/*, /api/csrf30 per minute
ai_command/admin/ai/command, /admin/ai/workflow20 per minute
admin_mutationOther non-GET /admin/* routes60 per minute
public_readGET /public/*300 per minute
defaultEverything else120 per minute

On tenant routes (/api/tenants/:tenantSlug/...), host.rateLimits({ tenantSlug, category, defaultPolicy }) can return { windowMs, limit, tenantLimit?, upgradeUrl? }. Buckets are keyed by tenant, so one tenant's traffic never uses another tenant's budget. limit applies per caller. tenantLimit is an optional budget shared by all callers of that tenant in the category. Return nothing to keep the framework policy.

Counters live in isolate memory and KV without atomic increments, the same as the framework policies. Under concurrent traffic across isolates, the effective budget can exceed limit and tenantLimit. For exact enforcement, apply the limit in the host with a Durable Object or Cloudflare's rate limiting binding.

The slug comes from the URL and is not yet verified, so a request for an unknown tenant reaches your resolver too. Answer unknown slugs quickly (for example from a cached tenant list). Results are cached per app instance for 30 s, per tenant and category. Invalid policies (non-positive or non-integer numbers) and a resolver that throws fall back to the framework policy. Over-limit requests get 429 RATE_LIMITED with Retry-After and details: { dimension: <category>, kind: 'rate', scope: 'caller' | 'tenant', limit, retryAfterSeconds, upgradeUrl? }.

Public delivery ABAC (host.publicAbac)

Off by default. When enabled: true, public content routes (list / entry / singleton) evaluate host grants against delivery attributes (locale, channel, optional brand / env) before serving. A mismatch returns 403 ABAC_DENIED with x-edgecms-policy-receipt and appends abac.denied. This is a host delivery policy — not user RBAC rows, not per-entry ACL. Assets and GraphQL are out of scope for this slice. See Authentication and access.

Edit docs/public/hosting-seams.md on GitHub