# 07 · Server Backends: WebAssembly Components

This is the implemented, end-to-end path for an extension that runs **server-side
code** on a cedros-data host. An extension backend is **first-class Rust compiled
to a WebAssembly component**, uploaded as part of the package, hot-loaded by the
host, and capability-gated by the manifest. No host rebuild, no restart.

This document is **canonical** for everything a server backend can do: routes,
jobs, database, settings, secrets, telemetry, providers, customer tags, CRM
source events, capability-gated extension services, outbound HTTP, and the
public-SEO seam. Surface docs
([`08`](08-site-database.md), [`09`](09-settings-secrets-and-state.md),
[`10`](10-host-providers.md), [`11`](11-jobs-and-scheduling.md),
[`16`](16-analytics-and-site-brain.md), [`20`](20-crm-tags-and-source-events.md))
add planning and data-design guidance; when they disagree with this document,
this document wins.

> Status: implemented and tested (cedros-data `wasm-extensions` feature). The
> in-process Rust-crate binding described in
> [`21-host-coordination-and-native.md`](21-host-coordination-and-native.md) is
> **not** how third-party backends run; they run as wasm components, described
> here.

## Confirm the host first {#confirm-the-host-first}

The seam set evolves, and the runtime is off by default, so before building, hit
`GET /admin/runtime/wasm-extensions/capabilities` on your target host. It returns
`wasmExtensionsEnabled`, the WIT `package`/`version` to build against, the
host-owned `hostCapabilities` ids, the world's import seam set + how each is
granted, the DB scope verbs, provider kinds and provider operations,
the runtime limits your component runs under, the host artifact-cache identity
and cache capacity, and whether secrets/egress are configured. A `false` or a
version mismatch means the host can't run your component — fix that before you
package, not at the load-time type check.

One caveat: the endpoint requires an authenticated admin with `SettingsRead` —
it is not anonymously probeable. The seam report is test-pinned to the WIT world
(`server/wit/cedros-extension.wit`) and the providers list to what
`providers.execute` actually routes, so the report matches what your component
links against — including `crm-source-events` and the exact provider spellings
(`imageGeneration`, `blockchain`, …).

## The model in one paragraph

The author writes the backend in Rust, generates guest bindings for the
`cedros:extension` world (`server/wit/cedros-extension.wit`), and compiles to the
`wasm32-wasip2` target — which emits a portable component. The component is placed
in the package archive at `cedros-extension.server.wasm`. On install the host
extracts it, verifies it loads under exactly the capabilities the manifest grants,
and persists it. At boot (and on every install/enable/disable) the host loads each
enabled component, asks it which routes it serves, and binds them under
`/extensions/{extension-id}`. Inbound requests are dispatched into the component;
the component reaches the site database and other capability seams only through
host imports it was granted.

**Getting the WIT.** The extension authoring kit includes the ABI contract at
`wit/cedros-extension.wit`; it is byte-identical to
`server/wit/cedros-extension.wit` in the cedros-data host repo. **Vendor it
byte-for-byte** into your extension repo and build bindings against your
vendored copy; pin to the WIT version and checksum the kit manifest and
[capability endpoint](#confirm-the-host-first) reports for your target host. A
tailored `world` that imports only the seams you use (e.g. omitting
`customer-tags`) is fine — keep the interface *definitions* identical to the
host's; only the `world`'s import list may be narrower. (There is no published
WIT package yet; vendoring from the host repo is the supported path.)

Do not edit interfaces out of the canonical file. Add a second WIT file in the
same package containing only a narrower world, then point `wit-bindgen` at that
world. The runnable
[`extension-with-server-wasm`](examples/extension-with-server-wasm/) example
vendors the canonical file unchanged and defines a `route-only` world that
imports only always-granted `logging` and exports `routes` plus `jobs`. Importing
any other interface without its corresponding manifest grant fails closed at
component instantiation.

## The WIT worlds

The WIT file defines **two** worlds. Almost every extension implements
`extension`; an extension that serves dynamic, parameterized public routes
(`/product/{slug}`-style) and declares `seoRoutes[]` implements the superset
`extension-seo` instead.

```wit
world extension {
    import logging;     // always granted
    import secrets;     // always granted (per-extension, isolated, encrypted)
    import database;    // granted iff the manifest declares databaseAccess
    import settings;    // granted iff the manifest declares settingsSchemas
    import telemetry;   // granted iff the manifest declares server events
    import providers;   // granted iff the manifest declares providerAccess
    import customer-tags; // granted iff the manifest declares customerTagAccess
    import crm-source-events; // granted iff the manifest declares crmSourceAccess[]
    import http;        // always linked; egress enforced at call vs operator allowlist
    import extension-services; // linked for declared extension or Core service capabilities
    import controls-privacy;   // reserved: linked only for cedros-controls with its required
                               // controls-publication-v1 contract; unavailable to other guests
    export routes;      // list-routes() + handle-route(request)
    export jobs;        // list-jobs() + run-job(execution)
}

world extension-seo {
    include extension;
    export seo;         // describe-route() + list-sitemap-urls()
}
```

- **`routes.list-routes() -> [route-spec]`** — the guest declares its routes
  (`route-id`, `method`, path relative to its namespace). The host binds each at
  `/extensions/{id}{path}`. Each `route-id` must appear in
  `surfaces.server.routes[]` (enforced at bind time).
- **`routes.handle-route(request) -> response`** — handles one inbound request.
  `request` carries method, path, headers, decoded ordered query pairs, the JSON
  body, and a host context envelope (see below).
  - **Body contract — JSON only, never raw bytes**: the host requires the body
    to be valid JSON and ≤2 MiB; a non-JSON body is rejected with a 400 **before
    the guest runs**, and an empty body becomes JSON `null`. The guest receives
    a canonical **re-serialization** of the parsed JSON, never the raw inbound
    bytes — so byte-exact raw-body HMAC verification (Stripe-style webhook
    signatures) is **impossible**, and form-encoded or binary webhooks 400
    before reaching the guest. Authenticate inbound webhooks with a
    token-in-URL or header-based verification scheme instead; if a partner
    strictly requires raw-body signature verification, flag a raw-body
    passthrough need as host-coordinated.
  - **Headers**: inbound request headers are collapsed into a unique-name map —
    a repeated header name keeps only the **last** value. Response headers are
    ordered pairs, so emitting multiple `Set-Cookie` headers is fine.
  - **Full-path contract**: the host passes the concrete mounted request path to
    `handle-route`, e.g. `/extensions/cedros-login/admin/settings`, not just
    `/admin/settings`. Strip the extension mount prefix before routing inside
    the guest:

    ```rust
    let mount = format!("/extensions/{extension_id}");
    let local_path = request.path.strip_prefix(&mount).unwrap_or(&request.path);
    ```

    Forgetting this does not prevent host binding, but it can make every handler
    miss internally and return extension-owned 404s.
  - **Path params**: a path segment of the form `{name}` is a template — it
    matches any one non-empty segment (`/users/{id}/kyc`). An exact/literal route
    always wins over a template at the same shape. The host passes the **concrete**
    matched path to the guest; extract the params yourself from `request.path`
    against your own template (no per-param plumbing in the WIT).
  - **Route id ↔ spec**: each `route-id` is unique per extension and backs exactly
    one `(method, path)` spec — `GET` and `POST` on the same path are **two**
    route-ids. The `(method, path)` pair must also be unique across the extension.
- **`database.read / count / write / delete / batch / compare-and-set / migrate`** — the validated
  site-database seam (see [`08-site-database.md`](08-site-database.md)). Each
  call is scope-checked against the manifest's `databaseAccess[]` (access id,
  verb, content model). `delete(access-id, content-model-id, entry-key) ->
  { removed }` removes one entry by key and is idempotent (`removed: false` when
  the key was absent). `read` accepts payload-field `filters`
  (`eq/ne/lt/lte/gt/gte/like`, JSONB value comparison — ISO-8601 strings compare
  chronologically) and an `orderBy` override; `count(...) -> { count }`
  aggregates the same filters server-side. `batch(access-id, ops-json) ->
  { applied }` applies multiple write/delete ops in **one transaction**
  (all-or-nothing, for atomic register = user + membership + audit + outbox),
  each op authorized against the existing write/delete scopes. Together these
  make expiry sweeps, admin stats/search, and atomic multi-record writes run in
  the database instead of as O(n) client scans or non-atomic call sequences.
  JSONB reads/writes carry an opaque `version`. Shared records use
  `compare-and-set` with an `absent` or expected-`version` condition; the typed
  conflict outcome commits nothing. Declare required capability
  `cedros-data:extension-storage-cas-v1`. See the exact schema, create/update/
  delete/reset rules, and legacy-write safety boundary in the database guide.
  Each verb used must be declared in the access's `scopes[]`. **Provisioning**:
  the host auto-creates every content model you declare in
  `surfaces.server.contentModels[]` as a JSONB collection **at install** — there
  is no install/startup export, so you do *not* call `migrate` from a route/job
  for ordinary collections; just declare them and read/write. An explicit
  `migrate` call can idempotently register that declared content-model id as
  `jsonb` or `typed`; it cannot execute a declared migration id or arbitrary SQL.
- **`settings.get / set / list-all`** — a per-extension key/value JSON settings
  store, scoped by extension id. Values are JSON strings.
- **`secrets.get / set / delete`** — a per-extension secret store, **encrypted at
  rest** and fully isolated from other extensions. `get` and `set` require the
  host to be configured with `CEDROS_EXTENSION_SECRETS_PASSPHRASE`; without it
  they fail closed. `delete` never touches the passphrase and still removes the
  stored ciphertext.
- **`telemetry.emit-event`** — emit an analytics event attributed to the
  extension (`event-id`, `label`, JSON metadata).
- **`telemetry.emit-record`** — publish a durable site-brain record (operational
  log: `category`, `title`, `text`, plus optional `summary`/`subject`/`tags`/
  `details`), attributed to the extension. The telemetry interface is still
  linked by `server.events`, but the durable write is denied unless the manifest
  also declares a `capabilities[].requestedScopes[]` write resource of
  `site-brain-record:<category>`.
- **`customer-tags.assign / unassign / read`** — read/write CRM customer-tag
  assignments through the host's validated bridge, scoped per call against
  `customerTagAccess[]` (verb + managed tag slugs). `profile-id` is a UUID;
  managed tag slugs must be namespaced `{extensionId}:`.
- **`crm-source-events.submit(access-id, source-kind, event-type, payload-json)`** —
  submit source events into Data-owned CRM linking, scoped against
  `crmSourceAccess[]`. Third-party payment attribution uses source kind
  `cedros_pay`; reserved first-party account linking uses `cedros_login` only
  from the bundled `cedros-login` extension and only for
  `account.merged`, `identifier.linked`, and `identifier.unlinked` events.
  Payloads must include deterministic identity and must not include raw wallet
  addresses.
- **`providers.execute(access-id, provider, operation, payload-json)`** — call a
  site-owned provider the operator granted in `providerAccess[]`. The call is
  scope-checked against the named access entry, and the host owns provider
  selection, identity, and limits:
  Optional top-level `timeoutMs` (integer 1..=60000) is consumed by the host
  adapter before validating/dispatching the provider payload. It bounds the
  complete awaited provider call, independently of larger configured host
  budgets. Extensions relying on this for an expiring CAS lease must require
  `cedros-data:database-concurrency-provider-deadline-v1`; its existing
  `database-concurrency` family prefix makes older hosts reject installation
  and activation. Recheck the lease immediately before dispatch and reserve
  time for committing the fenced result. Cancellation bounds local work and
  transport, not whether a remote provider has already processed the request.
  - **storage** — `put`/`get`/`delete` files in the site's media storage under an
    extension-namespaced key. Storage is always available in standard builds:
    configured object storage is used when present; otherwise the host falls
    back to local starter storage under `.cedros-data/local-media`. Local starter
    storage is deliberately limited to 20 extension objects per extension
    namespace and 10 MiB per `put`; new uploads fail closed once full, while
    replacing an existing object remains allowed.
  - **intelligence / imageGeneration / voice** — run through the main backend's
    CMS AI gateway (built from the site's configured AI providers); the payload is
    an `AiExecutionRequest`. The gateway enforces model allowlisting, capability
    checks, input-size limits, and rate limiting. A grant is operation-scoped
    (an `intelligence` grant can't run image/voice ops).
  - **email** — send through the site's default email provider with the site's
    sender identity and rate limits. Email has no local fallback; without an
    active sending provider the call fails with code
    `cedros.provider.not_configured` and category `provider_not_configured`.
  - **blockchain** — read shared wallet/RPC configuration with
    `blockchain.config.read`. The response includes `mainWalletAddress`,
    `solana.rpcUrl`, `solana.rpcSecretRef`, and `solana.rpcSecretConfigured`;
    the host never returns the raw RPC secret value.
  - **domains** — intentionally not exposed here; it is owned by a
    host-managed domain provider.
- **`providers.resolve-default(access-id, provider, operation)`** — resolve the
  host-selected intelligence provider/model after the same access-id, provider,
  and operation-scope checks. It returns only
  `{ status: "configured", provider, model }`; it never returns credentials or
  secret references. Use the returned pair in `providers.execute`.
- **`http.fetch(request-json)`** — outbound HTTP, restricted to an
  operator-approved host allowlist (e.g. OIDC discovery / JWKS / token exchange
  for federated login). Unlike every other capability this is **operator-granted,
  not manifest-declared**: the host reads an allowlist keyed by extension id from
  `CEDROS_EXTENSION_HTTP_ALLOWLIST` (JSON `{ "<extensionId>": ["host", …] }`).
  Ordinary requests must be **https** and target an allowlisted host or they fail closed
  before any network I/O; responses are bounded by a timeout and size cap.
  `request-json` is `{ method, url, headers?, body?, timeoutMs? }`; the result is
  `{ status, headers, body }`. The interface is **always linked**, so a component
  may reference `http.fetch` and still load — but it is **enforced at call time**:
  with no allowlist configured **ordinary calls return an error** (egress degrades
  gracefully), and egress lights up only when the operator grants hosts. So
  optional egress (e.g. social/SSO that not every site uses) needs no separate
  component — gate the *feature*, not the import.
  An optional integer `timeoutMs` from 1 through 50000 shortens the total
  DNS/header/body budget; omission keeps the 50-second default. Timeout drops
  the awaited request future, without automatically retrying mutations. Older
  hosts may ignore this additive JSON field, so clients must retain their own
  response deadline and must not treat it as a negotiated cancellation guarantee.
  **Public UCP profiles:** with the required host capability
  `cedros-data:ucp-platform-profiles-v1`, Cedros Pay may set `ucpProfile: true`
  to discover any platform's public HTTPS profile without an operator host grant.
  This mode accepts only GET on port 443, no body/query/fragment/URL credentials,
  and exactly `Accept: application/json`. It pins DNS, blocks every private or
  reserved address (including private self-origin exceptions), never follows
  redirects, caps responses at 128 KiB, and requires HTTP 200 UCP JSON with
  `ucp.version`, `ucp.capabilities`, and `signing_keys`. Only the `ucp` and
  `signing_keys` fields are returned. This grants no buyer or management identity.
  Other extensions and other HTTP operations retain the operator allowlist.
  Core also preserves raw UCP HTTP body bytes through WIT, including empty bodies,
  whitespace and key order. UCP routes require this public HTTP transport; a
  service invocation with parsed JSON is rejected. Duplicate signature headers
  are rejected before the guest header map can collapse them.
- **`extension-services.invoke(request)`** — call a declared route on another
  active extension or a published Cedros Data Core service without HTTP egress
  or caller-supplied credentials. The
  request contains `target-extension-id`, `route-id`, `capability-id`, `method`,
  the concrete mounted `path`, ordered `query` pairs, and `body-json`. It has no
  headers field. The host derives the caller from the running Wasm instance,
  validates the caller's extension/version/capability dependencies and aligned
  `requiredCapabilities[]`, verifies an extension provider publishes and binds
  the capability or that Core advertises the exact host capability, then
  supplies `authenticated: true`, the caller id as
  `actorId`/`principalId`, `authScheme: "extension-service"`, and exactly one
  `permissionClaims[]` operation. Self-calls, cycles, excessive call depth,
  undeclared routes/grants, and unavailable providers fail closed. Provider HTTP
  status/body semantics pass through; credential and browser-policy response
  headers are removed. This import is not browser-callable and cannot be
  replaced by a forged call to the public route or `http.fetch`.
- **`jobs` (export)** — `list-jobs()` declares the jobs the extension runs;
  `run-job(execution)` runs one. The host binds each declared id (it must appear
  in `surfaces.server.jobs[]`) to the scheduler. See
  [`11-jobs-and-scheduling.md`](11-jobs-and-scheduling.md).
- **`seo` (export, `extension-seo` world only)** — the public-route SEO seam for
  extensions that serve dynamic, parameterized public routes. See
  [the section below](#the-seo-export-extension-seo-world) and the SEO contract
  in [`13-public-pages-and-seo.md`](13-public-pages-and-seo.md).
- **`logging.log`** — diagnostics through the host tracing pipeline. Guest log
  lines are attributed to the extension id, control-character-stripped, and
  truncated at 4 KiB.

## Capabilities are interfaces

A capability is a WIT interface. The host links an interface into the guest **only
when the manifest grants it** (installing an extension is the operator granting
what its manifest declares). A component that imports a capability it was not
granted **fails closed at load** — the import is unsatisfied, so it is rejected at
install and never served.

| Interface | Granted when | Backing |
|---|---|---|
| `logging` | always | host tracing |
| `secrets` | always (isolated + encrypted) | host seam client in `wasm_extension_seams.rs`, age-scrypt-sealed |
| `database` | `databaseAccess[]` declared | validated Postgres content store |
| `settings` | `settingsSchemas[]` declared | host seam client in `wasm_extension_seams.rs` (JSONB) |
| `telemetry` | `surfaces.server.events[]` declared | analytics events; site-brain records require a matching `site-brain-record:<category>` write scope |
| `providers` | `providerAccess[]` declared | site-owned providers: storage, intelligence, image, voice, email, blockchain (domains is managed-only) |
| `customer-tags` | `customerTagAccess[]` declared | validated CRM customer-tag bridge |
| `crm-source-events` | `crmSourceAccess[]` declared | validated CRM source-event bridge |
| `http` | always linked; egress enforced at call vs operator allowlist (`CEDROS_EXTENSION_HTTP_ALLOWLIST`) | reqwest, https-only, host-allowlisted, SSRF-guarded, timeout + size capped; empty allowlist denies all |

`logging` and `secrets` are always granted because each is benign-to-the-host: a
per-extension isolated store reaches no host resource or other extension.

**Providers wired:** storage, intelligence, imageGeneration, voice, email, and
blockchain all route to the main backend's own subsystems (media storage, the CMS
AI gateway, the configured email provider, and site-owned wallet/RPC settings).
Only **domains** is intentionally not exposed — it is owned by a host-managed
domain provider and returns "not available".

### Provider operation contracts

`providers.execute` and `providers.resolve-default` return JSON strings on
success. Provider host errors keep
the human `message` field and also include stable `code` and `category` fields.
Extensions should branch on those stable fields, not on English text. Current
provider configuration codes are:

| Code | Category | Meaning |
|---|---|---|
| `cedros.provider.not_configured` | `provider_not_configured` | The provider family is wired but the operator has not configured an active provider, for example email sending. |
| `cedros.provider.denied` | `provider_denied` | The manifest grant/scope is missing or the requested AI target is blocked by site policy/model allowlists. |
| `cedros.provider.invalid_request` | `provider_invalid_request` | A provider kind, operation, or provider payload is malformed. |
| `cedros.provider.unavailable` | `provider_unavailable` | The provider family is not routed on this host, for example `domains` through the wasm provider import. |

Storage operations use the extension-local `key` supplied by the caller. The
host normalizes it under `extensions/{extensionId}/...`; callers may also pass a
previously returned namespaced key back to `get` or `delete`.
Declare `storage.read` to call `get`; declare `storage.write` to call `put` or
`delete`.

| Operation | Request payload | Success response |
|---|---|---|
| `put` | `{ "key": string, "content": "<base64>", "contentType"?: string }` | `{ "key": "extensions/{extensionId}/..." }` |
| `get` | `{ "key": string }` | `{ "key": "extensions/{extensionId}/...", "size": number, "content": "<base64>" }` |
| `delete` | `{ "key": string }` | `{ "key": "extensions/{extensionId}/...", "deleted": true }` |

Storage failures surface as code-bearing host errors under category
`storage_failed` (not the AI/provider-config categories above); a `get` on a
missing key is one of these (an IO/not-found error, **not** an empty or null
success), so branch on `storage_failed` and treat get-on-missing as "absent".

There is no presigned-URL operation. To serve stored bytes, expose an
authenticated extension route that reads through the seam and streams or returns
the data.

#### Hosting files: decision guide

- **Internal files** (extension's own data) — use the storage seam
  `put`/`get`/`delete` and serve the bytes through your **own** authenticated
  extension route. There is no public or signed URL into storage.
- **Public download** — build your own route that streams the bytes returned by
  `get` (within the response-size cap); there is no host-served public file URL.
- **Paid/gated media** — this seam is the wrong surface. Gated/paywalled content
  is a separate **host** surface requiring Cedros Pay 2.0.0 + Cedros Login and is
  **not** reachable via `providers.execute`; see
  [`contracts/gated-media-content.md`](contracts/gated-media-content.md).

> **Common wrong assumption:** you can presign, stream, or chunk an upload.
> **Actually:** an upload is inline base64 in **one** `put` call — no presign, no
> streaming, no chunked/multipart. The 10 MiB-per-`put` and 20-object caps apply
> to the **local starter fallback only**; with object storage configured, a `put`
> is bounded only by the wasm invocation limits (256 MiB linear memory, the 5 s
> CPU-epoch / 30 s whole-invocation / 20 s per-host-call timeouts) plus ~33% base64
> inflation, so very large files stay impractical.
> (Source: `server/src/wasm_extension_providers.rs` storage seam; `presign` is a
> future candidate only.)

Email sends declare scope `email.send`, call operation `send`, and use payload
`{ "to": string, "subject": string, "htmlBody"?: string, "textBody"?: string }`.
At least one body field is required. A successful send returns
`{ "sent": true, "provider": string }`; rate limits return the host rate-limit
code, and downstream send failures fail closed.

## The host context envelope

Every `handle-route` call carries a host-populated context (as JSON):
`actorId`, `authenticated`, `systemAdmin`, `permissionClaims`, `requestId`,
`clientIp`, `idempotencyKey`, `csrfVerified`, `siteId`, `environment`. The host
resolves the admin session and populates `authenticated`/`actorId` truthfully.
`csrfVerified` is true for bearer-header (Authorization) auth **or** for
cookie-backed requests whose `Origin` is the API origin or an operator-trusted admin origin; it is
false for bare cookie requests with no trusted `Origin` — **fail closed on
state-changing routes when it is false**, and treat external webhooks as
unverified (authenticate them with a token-in-URL or header scheme; raw-body
signature verification is impossible — see the body contract above).

**Context envelope (`context-json`), exact fields (camelCase):** `authenticated`
(bool), `systemAdmin` (bool, always present), `actorId` (string?),
`permissionClaims` (string[]; omitted from the JSON when empty), `requestId`
(string?), `clientIp` (string?), `idempotencyKey` (string?), `csrfVerified`
(bool), `siteId` (string?), `environment` (string?).
`authenticated`/`csrfVerified` default to `false` and the optionals are omitted
when absent — handlers must **fail closed** on them, never assume a field is
present.

`clientIp` is resolved by the host from the socket peer plus its trusted-proxy
boundary. It is the only authoritative client-address field. Do not derive a
security decision from `Forwarded`, `X-Forwarded-For`, `X-Real-IP`, or vendor
forwarding headers in the request header map.

Three of these are filled directly from inbound request headers (case-insensitive):
`requestId` <- `x-request-id`, `idempotencyKey` <- `idempotency-key`, and `siteId`
<- `x-cedros-site-id` (only when the admin session did not already resolve a site).
To do an idempotent write, send `Idempotency-Key` and key off `idempotencyKey`.

> **Common wrong assumption:** there is a `claims` object with `claims.sub` / a
> `role` field. **Actually:** there is no `claims` object and no `claims.sub` —
> the caller subject is the top-level `actorId` string (omitted entirely when
> unauthenticated). There is no `role`/capability object either; authorization is
> the flat `permissionClaims` array (example values: `manage_settings`,
> `manage_users`, `manage_extensions`, `data:settings:read`, `data:settings:write`,
> and `*` for full admin) plus the `systemAdmin` bool.
> (Source: `server/src/extension_platform_host.rs`; claim names
> `server/src/http_auth.rs`.)

### Admin session vs. an extension's own end users

`authenticated` / `actorId` / `permissionClaims` describe the **cedros-data
admin** session *only* — they are how you gate **admin-management** routes. They
are **not** an extension's own end-user session. An identity-style extension
that serves **public** routes (register, login, password reset, OAuth callback)
runs in its **own** auth domain: it reads its session from the request `headers`
(cookie/bearer) and issues sessions via the response `headers` (`Set-Cookie`),
and serves those routes with `authenticated = false` (the normal, correct state
— the host applies no admin gate to extension routes). So "fail closed when
`authenticated` is false" is guidance for *admin-protected* routes, not an IdP's
public surface. `csrfVerified` is likewise an **admin**-write gate; a public
form scheme enforces its own CSRF.

### Public site proxy contract

The public site server forwards safe `/extensions/{extension-id}/...` requests
to the Cedros Data backend. `surfaces.server.routes[]` plus the Wasm
`list-routes()` method/path declaration is the complete executable-route
contract; there is no separate public-runtime-route manifest field. The backend
registry remains authoritative and returns 404 for paths or methods the enabled
extension did not bind.

The proxy preserves the request method, query string, JSON body (up to the
standard 2 MiB extension-route limit), and selected content-negotiation headers.
It forwards only the approved Cedros Login/customer cookies, never the
`cedros_local_admin_session` cookie. The response preserves the status, body,
content type, redirect `Location`, cache validators/directives, and
`Set-Cookie`. Redirects are relayed rather than followed by the proxy.

This transport does not make a route public in the authorization sense.
Customer routes must validate their extension-owned login session, and admin
routes must continue to fail closed on the host-authenticated context and
permission claims. Public proxying never turns a customer session into a Cedros
Data admin session.

## The seo export (extension-seo world)

An extension that serves dynamic, parameterized public routes (product pages,
listing detail pages, …) declares them in the manifest's `seoRoutes[]` and
implements the `extension-seo` world. The host instantiates the guest against
`extension-seo` only when the manifest declares `seoRoutes[]`; otherwise it uses
`extension`, so non-SEO extensions never implement the export. Declaring
**dynamic** (parameterized) `seoRoutes[]` without exporting `seo` fails install
with a clear message — the pairing is a hard gate
(`server/src/extension_runtime_host.rs`).

The guest returns **declarative** SEO data as JSON; Cedros Core owns all
head/canonical/JSON-LD/sitemap emission (extensions never emit `<head>` markup —
see [`13-public-pages-and-seo.md`](13-public-pages-and-seo.md) for the full SEO
contract, precedence order, and the `seoRoutes[]` manifest fields):

- **`describe-route(request-json) -> option<string>`** — describes SEO for one
  concrete path. Input: `{ "path": string, "locale": string }`. The host passes
  only route context; the guest **self-sources** its content through its own
  `database` capability. Returns a JSON route description
  (`{ "revision", "title", "description", "indexable", "breadcrumbs"?, "seo": { "schema":
  { "kind": "Product", … } } }`) or `none` when the path does not resolve (the host then
  serves 404/410).
- **`list-sitemap-urls(request-json) -> string`** — enumerates the extension's
  indexable public URLs for the host sitemap. Input: `{ "origin": string }`.
  Returns `{ "urls": [{ "loc", "revision", "lastmod"?, "changefreq"?, "priority"?,
  "servedLocales"?, "images"? }] }`. Each `loc` must be a clean same-origin absolute path
  matching a declared dynamic `seoRoutes` pattern. The host bounds payload and
  entry counts, restricts alternates to that route's declared locales, validates
  optional hints, and dedupes.

When a route is sitemap-enabled, Core batch-resolves every listed locale,
requires list/describe to declare the same non-empty revision, validates the fully composed
document, and atomically publishes one snapshot for both HTML and sitemap.
Invalid or partially unavailable provider output leaves the last-known-good
snapshot active. Only authoritative `none` is a 404; dependency failures are
503.

The public resolution endpoint is `GET /content/extension-seo` on the host: it
matches the requested path against installed extensions' declared `seoRoutes[]`
and invokes the matched extension's `describe-route`.

## Packaging

The package archive ([`22-packaging-and-upload.md`](22-packaging-and-upload.md))
additionally carries the component:

```
cedros-extension.manifest.json     # declares routes, databaseAccess, etc.
cedros-extension.server.wasm       # the compiled component
```

The manifest's `surfaces.server.routes[]` must list every `route-id` the guest
returns from `list-routes`. The compiled artifact is portable — one `.wasm` runs
on any cedros-data host regardless of OS/CPU.

## Runtime-enforced rules

These are enforced by the host at install or call time; violating them fails
the install or traps/degrades the call.

- **Reverse binding admission.** Every manifest-declared `routes[]`/`jobs[]` id
  **must** be returned by the guest's `list-routes`/`list-jobs`, or install
  fails atomically — one extra declared id is enough to reject the whole
  install. (The forward direction — every returned id must be declared — is
  documented above.)
- **`compatibility.witChecksum`.** Optional manifest pin of the host WIT
  sha256; a mismatch rejects install with a clear message. The capability
  endpoint reports `wit.checksum`. This is the reliable ABI pin — the WIT
  version string has lagged before (the `extension-seo` world shipped as an
  additive change with the package version still `0.1.0`).
- **Execution limits** (defaults; all tunable via `CEDROS_WASM_*` env vars):
  5 s guest CPU epoch deadline, 60 s whole-invocation timeout, 55 s per
  host-import call, 256 MiB linear memory, 2 MiB wasm stack. Exceeding any of
  these traps the call. Guest log lines are control-character-stripped and
  truncated at 4 KiB.
- **`http.fetch` numbers.** 50 s total / 8 s connect timeout, 8 MiB response
  cap, ≤40 request headers, redirects are never followed, and
  internal/reserved IPs are blocked even when the host is allowlisted (SSRF
  guard: loopback, RFC-1918, link-local, CGNAT, and reserved ranges are denied
  even for an allowlisted hostname).
- **`extension-services` limits.** At most 64 query pairs, 4 KiB per query name
  or value, 4 MiB request/response bodies, 64 safe response headers, and eight
  extensions in one acyclic call chain. Each nested import also remains subject
  to the normal host-call and whole-invocation deadlines.
- **`database.read` default page size is 100** entries when no `limit` is
  given — sweeps must paginate or they silently process only 100 rows.
- **Per-request instantiation.** Each `handle-route`/`run-job` gets a fresh
  Store/instance — no guest state survives between requests. Caches (e.g. a
  JWKS cache) must live in settings or the DB, not guest globals.
- **Malformed guest JSON args degrade silently.** A non-JSON `query-json`/
  `payload-json` string becomes `null`/`{}` rather than an error — a typo'd
  read query returns the default-ordered first 100 rows, not a failure.
- **`telemetry.emit-event` can silently no-op** when the site's analytics
  settings disable extension events. Event ids are validated per call against
  `surfaces.server.events[]`, but success is not a durable notification
  acceptance/status API; any configured notification-route dispatch is
  best-effort from the caller's perspective.
- **No server component ⇒ `{"installed": null}`.** Installing an archive with
  no server component succeeds but binds nothing — check for this when
  debugging "install succeeded but no routes".
- **Archive composition (server install endpoint).** Request body ≤64 MiB,
  component ≤64 MiB, all other entries UTF-8 text ≤2 MiB each, ≤8 MiB total,
  ≤4096 files.

**Clocks and randomness:** the WASI p2 wall + monotonic clocks are linked, so
`chrono::Utc::now()` works in-guest — you do not need the host to thread "now"
through job metadata. Randomness via `getrandom` likewise works. There is no
filesystem, no network (outside `http.fetch`), no environment variables, and no
stdio.

**Sandbox profile:** the guest gets an empty WASI context (no FS/net/env), a
fresh store per invocation, a metered resource limiter, and epoch-based CPU
interruption. Components are compiled from raw bytes with
`Component::from_binary` on every host (never deserialized from a persisted
precompiled artifact) and cached in-process keyed by content hash + wasmtime
version + WIT checksum + runtime config.

## Database operation response shapes

So you don't have to parse defensively:

- `read` → `{ "entries": [ { "entry_key": string, "payload": object, "updated_at": rfc3339, "version": string|null } ] }` — JSONB versions are opaque equality tokens; typed collections return null.
- `write` → `{ "entry": EntryRecord }` (same row shape).
- `count` → `{ "count": number }`.
- `delete` → `{ "removed": boolean }`.
- unguarded `batch` → `{ "outcome": "applied", "applied", "results", "entries", "replayed" }` (legacy clients may observe the older `{ "applied" }` subset).
- `compare-and-set` → WIT `cas-outcome::applied(result-json)` or typed
  `cas-outcome::conflict(detail)` with zero mutations.
- `migrate` → `{ "collectionName": string }`.

From the wasm seam, `migrate(access-id, content-model-id, mode)` **always**
targets a `contentModelId` — there is no payload `collectionName` or
`migrationId` parameter; that form exists only for in-process callers.

## Lifecycle (admin endpoints, ManageExtensions permission)

| Action | Endpoint |
|---|---|
| Install (upload archive) | `POST /admin/runtime/wasm-extensions/install` |
| Enable | `POST /admin/runtime/wasm-extensions/{id}/enable` |
| Disable | `POST /admin/runtime/wasm-extensions/{id}/disable` |
| Uninstall | `DELETE /admin/runtime/wasm-extensions/{id}` |

Each mutates persisted state and **hot-swaps** the runtime registry, so the change
takes effect immediately — no server restart. In-flight requests complete against
the old component; new requests use the new one. Uninstall can optionally purge
the extension's settings, secrets, and namespaced database entries.

## Admin UI integration

An installed extension's React admin module receives a `serverUrl` pointing at the
cedros-data host. It calls its backend at `{serverUrl}/extensions/{id}{path}` (the
namespace the host bound). The browser-loaded UI is dynamic already; only the
backend needed the host-side execution this document describes.

## Authoring checklist

1. Implement the `cedros:extension` world in Rust (`wit-bindgen`), exporting
   `routes` (`list-routes` + `handle-route`) and `jobs` (`list-jobs` +
   `run-job`). An extension with no jobs returns an empty `list-jobs`. If the
   manifest declares dynamic `seoRoutes[]`, implement `extension-seo` and export
   `seo` as well.
2. Use only host imports your manifest grants; reach the DB through `database.*`,
   never your own pool.
3. Fail closed when `authenticated` is false (and on `csrfVerified` for writes)
   on admin-protected routes; public routes enforce their own auth scheme.
4. Declare what you use in the manifest — each is what grants the capability:
   every served `route-id` in `surfaces.server.routes[]`; every `job-id` in
   `surfaces.server.jobs[]`; `databaseAccess[]` (ids/scopes/content-models) for
   the DB; `settingsSchemas` for settings; `surfaces.server.events` for telemetry;
   `providerAccess[]` for providers. (Logging and secrets are always granted.)
5. Build for `wasm32-wasip2`, ship the component at `cedros-extension.server.wasm`.
6. Install via the endpoint; verify the route serves under `/extensions/{id}`.

See `server/tests/fixtures/wasm-extension-example/` in the cedros-data repo for
a minimal working guest.

## Troubleshooting routes and seeded-page 404s

| Symptom | Likely class | Check |
|---|---|---|
| Every `/extensions/{id}/...` route returns an empty 404 | Host route not bound | Confirm install response, extension enabled state, and server logs around install/reload/bind. The host fallback returns an empty 404 when `resolve_route` finds no binding. |
| A bound extension route returns a JSON/body 404 | Guest handler reached but missed | Confirm `handle-route` strips `/extensions/{id}` before internal routing, and that method/path templates match the concrete request. |
| `/login`, `/register`, or another seeded public page returns 404 after enable | Page seed not provisioned/published | Verify `pageSeeds[].autoCreateOnEnable`, `publishOnEnable`, `templateId`, and matching `surfaces.web.routeIds[]`. If those are complete, investigate host page-seed provisioning/logs rather than adding wasm route handlers. See [`13-public-pages-and-seo.md`](13-public-pages-and-seo.md). |

## Seam status (what you can build against today)

| Seam | Status |
|---|---|
| Routes (`list-routes` / `handle-route`) | **Ready** |
| Database | **Ready** (validated, scope-enforced) |
| Settings | **Ready** |
| Secrets | **Ready** (encrypted; `get`/`set` require `CEDROS_EXTENSION_SECRETS_PASSPHRASE`; `delete` works without it) |
| Telemetry — analytics events + site-brain records | **Ready** (`emit-event` rejects ids absent from `surfaces.server.events[]`; `emit-record` requires its category scope) |
| Logging | **Ready** |
| Customer tags (CRM) | **Ready** (validated bridge; assign/unassign/read) |
| CRM source events | **Ready** (validated bridge; `submit`) |
| Lifecycle (install/enable/disable/uninstall, hot-swap) | **Ready** |
| Server jobs / scheduled executors | **Ready** (list-jobs/run-job, bound to the scheduler) |
| Providers: storage / intelligence / image / voice / email / blockchain | **Ready** (scope-validated; routed to the main backend's storage, CMS AI gateway, configured email provider, and shared wallet/RPC config, all with the host's existing limits) |
| Outbound HTTP (`http.fetch`) | **Ready** — operator-allowlisted, https-only, timeout + size capped (`CEDROS_EXTENSION_HTTP_ALLOWLIST`) |
| Extension services (`extension-services.invoke`) | **Ready** — manifest/version/capability/route validated; host-derived caller and exact single operation claim |
| Public-route SEO (`seo` export, `extension-seo` world) | **Ready** — opt-in via `seoRoutes[]` |
| Provider: domains | **Not exposed** — host-managed domain provider concern |
| Native (React Native) screen/stack/provider loading | **Not a server seam** — wired by hosted App Builder's separate fail-closed mobile subsystem; other native hosts remain host-coordinated |

Teams can build extensions today using the full server surface — routes +
database + settings + secrets + telemetry (events + explicitly scoped site-brain
writes) + **jobs** + **customer-tags** + **crm-source-events** + **providers**
(storage, intelligence, image, voice, email, blockchain) + **outbound HTTP**
(operator-allowlisted) + the opt-in **seo** export — all self-serve. Deliberately
out of scope for the Wasm server: the host-managed **domains** provider and
**native (React Native)** loading (hosted App Builder wires this through its
separate mobile subsystem; other native hosts need an equivalent loader). The operator
vets each extension and grants its capabilities at install; the host enforces
the limits behind every capability.

## Relying-party and cross-extension composition

Some extensions are **relying parties**: they serve cedros-login *end users*
(not just admins) and need that end user's identity, or they need data another
extension owns (e.g. a compliance extension keying KYC status to a login user).
The platform composes these through **explicit contracts, not shared state** —
two rules:

- **The host never injects end-user identity.** `context-json` carries the
  cedros-data *admin* session only; the host does not (and must not) understand
  any extension's end-user session scheme. A relying party reads the end user's
  credential from the route request `headers` (cookie/bearer — it's a public
  route the end user hits) and **verifies it itself** against the identity
  extension's published contract: preferred is the IdP extension issuing JWT
  sessions + publishing a JWKS, which you fetch (and cache) via `http.fetch` and
  verify locally with no per-request round-trip; the fallback for opaque sessions
  is a session-introspection route the IdP exposes, which you call over
  `http.fetch`. Either way the host stays decoupled from identity.
- **Extension data is private; share it through published APIs, not tables.** The
  `database` seam exposes only *your* extension's content models — never the
  host's tables or another extension's data. Understand what that isolation
  actually is: per-call **declared-name scoping** (the content model must be in
  *that* manifest's declared list) plus a reserved first-party collection
  blocklist. Collections share one global keyspace with no owner column, and the
  database layer itself trusts declared names — manifest validation enforces
  `{extensionId}:` namespacing on `contentModels[]`, but the storage layer does
  not re-check ownership. So: always namespace content-model ids
  `{extensionId}:...`, and operators must review declared names at install. To
  consume data another extension owns (user records, counts,
  status), call that extension's **published HTTP route** over `http.fetch`; to
  *own* derived data, keep it in your own content models keyed by the external id
  you obtained (e.g. the login user id). There is no cross-extension DB access and
  no host `users` table to read — identity and its records belong to the identity
  extension.

End-user identity contracts still run over the egress seam, so the **operator
allowlists the site's own domain** for the relying party, and the
identity/owning extension must publish
the contract (JWKS / introspection / lookup routes) the relying party depends on
— coordinate that across the two extension teams. The **canonical contract shapes**
(JWKS, introspection, user lookup/count, credits, and the service-to-service auth
between extensions) are specified in
[`19-composability-and-relying-party-contracts.md`](19-composability-and-relying-party-contracts.md).
Cedros Balance `0.5.0` publishes the first official provider-side contract for
a capability-gated extension caller and Cedros Data exposes it through
`extension-services.invoke`. See
[`contracts/balance-rail.md`](contracts/balance-rail.md); rebuild consumers
against the current WIT and do not forge its `actorId` or operation claim
through HTTP inputs.

Cedros Data `0.1.62` also publishes Core target `cedros-data` for operational
wallet read and finalized inbound activity. It uses the same import and exact
capability checks but requires no `dependencies.extensions[]` row. See
[`contracts/operational-wallet-notification-seams.md`](contracts/operational-wallet-notification-seams.md).

Note on storage downloads: the storage provider is `put`/`get`/`delete` with no
presigned URLs. To serve a stored document, **proxy it through your own
authenticated route** (`get` → return the bytes from your handler), respecting
the response-size cap — do not expect to hand out a direct storage URL. If
offloaded direct downloads of large files become necessary, flag it as a
candidate `presign` storage operation.

Next: [`08-site-database.md`](08-site-database.md).
