# CloudForge Site Extensions - the guide

**Version 1.3.2 · 2026-10-08 · Site SDK 1.0.3** · replaces the April 2026 _Website Development
Guide_. Served at `https://www.cloudforge.co.za/cloudforge-site-extensions-guide.md`; linked from
`GET /v1` and `/v1/claude.md`.

This page is for two readers: the **customer** who runs a website on CloudForge, and the **AI
operator** building or changing that site from a brief ("add a contact form and a members area
to this site"). Everything here works on every kind of site CloudForge hosts - a static site you
upload, a site built in the page builder, or a site your own application serves behind the hub.

**In one sentence:** every CloudForge extension with a visitor-facing part is a **web component**
(`<cf-…>`) served by your own site's host from `/vendor/cf.js`; a static site pastes the tag, the
page builder wraps the same tag in a block, and the server enforces who may see what.

---

## 1. Vocabulary

| Word                        | Meaning                                                                                                                           |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| **Site**                    | What your domain serves. One site, many handlers.                                                                                 |
| **Handler**                 | How a path is served: the page **builder**, **static** files you uploaded, or a **forward** (redirect / proxy) to another system. |
| **Rule**                    | One line of Routing: path prefix → handler → access.                                                                              |
| **Extension**               | A CloudForge module with a public surface: forms, booking, blog, shop, support, analytics.                                        |
| **Component**               | The `<cf-…>` tag that puts an extension on a page.                                                                                |
| **Version**                 | A published snapshot of the site; publishing and rolling back are versions.                                                       |
| **Tenant / owner / member** | Your organisation on the platform, the person who runs it, a person with an account in it.                                        |

## 2. The routing model (one paragraph)

Your domain is served by an ordered list of **rules** you manage under **Admin → Website →
Routing** (owner only). A rule says what a path prefix is served by - the page builder, your
static files, or a forward to another system - and who may reach it: everyone, signed-in people,
members of your tenant, or a group. A site has a **default handler** for everything no rule
matches, so a static site can still have `/blog` from the builder and `/app/*` proxied to your
own application. Redirects are yours to create; proxy targets need platform approval. The full
screen, limits and the `/v1/tenants/{id}/website/routing` API are described on the Routing
screen and in the API guide - this page only needs you to know that **where traffic goes is set
in Routing, not in your HTML**.

## 3. Protecting paths - server-side, always

"Members only" is a **Routing rule** (`/members/*` → static → signed-in). The hub checks the
session before a single byte of the page is sent; a visitor who is not allowed is sent to sign
in and comes back afterwards.

**The old pattern is retired.** The April guide suggested a client-side check:

```js
// Do NOT do this any more
const session = await fetch('/api/auth/session').then((r) => r.json());
if (!session?.user) location.href = '/signin';
```

That only hid the page after it had already been delivered. Remove such snippets; add a Routing
rule instead. Inside a protected page you may still use `<cf-auth>` (section 5.1) to show who is
signed in - for display, never for protection.

## 4. Getting started

### 4.1 Static or template sites: one tag

Add this **once per page**, in `<head>`:

```html
<link rel="stylesheet" href="/vendor/cf.css" />
<script src="/vendor/cf.js" defer></script>
```

Both files are served by your own domain - no third-party host, nothing to whitelist, and they
work under the platform's strict Content-Security-Policy. The platform never edits your HTML;
you add the tag, you keep control.

### 4.2 Builder sites: nothing to add

Pages built in **Admin → Website → Pages** load the SDK automatically. Each component below is
also a **block** in the block picker (section 7); a builder page and a static page end up with
the same tag and the same behaviour.

### 4.3 What happens when the page loads

1. `cf.js` fetches your site's **config** once (`GET /api/public/site/config`): which extensions
   are enabled, your theme colours and fonts, and where sign-in lives.
2. Every `<cf-…>` tag on the page renders itself. A tag for an extension that is **not enabled**
   renders nothing and prints one line in the browser console
   (`[cf] cf-form: the "forms" extension is not enabled…`) so you know why.
3. Each component keeps its space while loading (`min-height`), so nothing jumps.

You can also drive it from JavaScript:

```js
const cfg = await cf.config(); // { tenant, modules, theme, auth }
await cf.modules.enabled('booking'); // true / false
const me = await cf.auth.session(); // { user: { name, email } } or null
cf('event', 'quote_requested', { plan: 'pro' }); // analytics (unchanged)
```

`cf('event', …)` is the analytics beacon you may already use; the SDK adds the namespaces beside
it, in either load order.

## 5. The components

Every component: accessible markup (real labels, keyboard operation, live regions for status),
themed from your tenant theme (section 6), and talking only to your own host. Attributes are
plain HTML attributes; a bare attribute (`compact`) means "on".

### 5.1 `<cf-auth>` - sign in / account (core, always available)

```html
<cf-auth></cf-auth>
<cf-auth compact></cf-auth>
<cf-auth label="Members area" portal-label="My dashboard" return="/members"></cf-auth>
```

Signed out: a sign-in link that returns to the current page (or `return`). Signed in: avatar
and name, a link to the portal (`/portal`), a sign-out button. Sign-out ends the session on your
site's host and returns to `return` (or `/`) on your site - never to the platform. `compact`
shows only the person; `hide-portal` drops the portal link.

### 5.2 `<cf-form name="…">` - forms (extension: **forms**)

```html
<cf-form name="contact"></cf-form> <cf-form name="quote-request" title="Get a quote"></cf-form>
```

`name` is the form's slug from **Admin → Forms**. `contact` is built in: if you have not defined
fields it renders Name, Email, Phone, Subject, Message. Fields, validation rules, button text,
thank-you message and redirect all come from the form's definition, so change the form once in
Admin and every page follows. **Where to manage it:** Admin → Forms (`/admin/forms`) - the form's
fields, spam settings, notification addresses and redirect; its **Submissions** tab lists what
came in (search, dates, mark read/spam, CSV export) and the list screen sets how long
submissions are kept. New submissions are e-mailed to the form's notify addresses.

Spam guard, built in: a honeypot field and a "submitted faster than a person could type" check
(3 s), plus **5 submissions per minute per address**. No CAPTCHA to configure. A field's
validation pattern (Admin → Forms) is applied server-side too, except a pattern that could
backtrack catastrophically (nested quantifiers such as `(a+)+`) - that one is skipped, never run.

Endpoints, for anyone posting by hand: `GET /api/public/site/forms/{slug}` (the definition) and
`POST /api/public/site/forms/{slug}` with `{"values": {...}}` - or the fields at the top level.
Errors come back as `{"error":{"code":"validation_failed","details":{"fields":{"email":["…"]}}}}`.

### 5.3 `<cf-booking>` - appointments (extension: **booking**)

```html
<cf-booking></cf-booking> <cf-booking service="svc_abc123" title="Book a consultation"></cf-booking>
```

Service (or fixed with `service="<id>"` - ids are on **Admin → Booking → Services**), date,
free time slot, name / e-mail / phone / notes → a booking in **PENDING** state that you confirm
in Admin. The slot is re-checked on the server under a lock, so two visitors cannot both take
the last one; a slot taken in the meantime asks the visitor to choose again. A service id that
is not yours (or is inactive) is simply "not found".

### 5.4 `<cf-blog list>` / `<cf-blog post>` - blog (extension: **blog**)

```html
<cf-blog list limit="6" columns="3"></cf-blog>
<cf-blog list href-base="/news/?post="></cf-blog>
<cf-blog post slug="welcome"></cf-blog>
<cf-blog post></cf-blog>
<!-- reads ?post=<slug> from the URL -->
```

`list` shows the latest published posts as cards linking to `href-base` + slug (default
`?post=<slug>` on the same page). `post` renders one post in full - with no `slug` it reads
`?post=` from the URL. So a single static page holding `<cf-blog list>` above `<cf-blog post>`
is a complete blog; set `href-base="/news/"` to link to a dedicated page instead.

The post body (`GET /api/public/site/blog?slug=…` → `content`) is the HTML as written in Admin.
The component renders it through the SDK's sanitiser (scripts, event handlers, `javascript:` /
`data:` links, inline styles and embeds removed); if you fetch it yourself, sanitise it the same
way before putting it in a page.

### 5.5 `<cf-shop products|cart|checkout>` - shop (extension: **ecommerce**)

```html
<cf-shop products limit="8" columns="4"></cf-shop>
<cf-shop cart checkout-href="/checkout.html"></cf-shop>
<cf-shop checkout></cf-shop>
```

`products` lists active products with "Add to cart" (`category="<id>"` filters). `cart` shows
the visitor's cart with quantity and remove; `checkout` takes details and addresses and places
the order. Guests get a cart through a cookie; a signed-in visitor gets their own. Payment is
handled by the shop's configured payment method after the order is placed.

**Where to manage it:** Admin → E-Commerce - Products and Orders as before; **Categories**
(`/admin/shop/categories`, the tree `category="<id>"` filters by), **Coupons**
(`/admin/shop/coupons`, the codes the cart and checkout accept - one validation for both), and
**Shipping** (`/admin/shop/shipping`, zones with flat / per-kilogram / free-over methods).
`checkout` asks `GET /api/portal/shop/checkout?tenantId=…&country=ZA&province=…` for the methods
that cover the address, each with its cost for the current cart; with no methods defined the
order carries no shipping charge.

### 5.6 `<cf-chat>`, `<cf-subscribe>`, `<cf-kb>` - no longer available

Live chat, email marketing (newsletter sign-up) and the knowledge base (help centre) left the
platform on 2026-09-23 to be developed on their own; they will come back as extensions you can
switch on under **Admin → Extensions**. A page that still carries one of these tags keeps
working: the tag renders nothing, calls nothing, and prints one line in the browser console
(`[cf] <cf-chat>: the Live Chat extension is no longer part of this platform…`). Remove the tag
when you next edit the page. Their endpoints (`/api/public/site/chat/…`, `…/subscribe`,
`…/unsubscribe`, `…/kb`) answer `404`.

### 5.7 `<cf-support-form>` - support requests (extension: **support**)

```html
<cf-support-form></cf-support-form>
<cf-support-form title="Report a problem" hide-category></cf-support-form>
```

Support requests belong to a person, so a signed-out visitor sees a sign-in prompt that returns
here; a signed-in one gets category, subject and description, and can follow the request under
**Portal → Support**.

### 5.8 Analytics (extension: **analytics**) - nothing to add

Page views, sessions, scroll depth, outbound clicks, downloads, form submits and Web Vitals are
recorded when the analytics extension is on and tracking is enabled in **Admin → Analytics**.
Custom events: `cf('event', 'name', { any: 'params' })`.

## 6. Theming

Components read CSS custom properties. `cf.js` sets them on `<html>` from your tenant theme
(**Admin → Website → Design**) - but only the ones your own CSS has not already defined, so you
can override any of them in your stylesheet and the SDK will not fight you:

```css
:root {
  --cf-primary: #0e7490; /* buttons, links, selected states */
  --cf-primary-fg: #ffffff;
  --cf-secondary: #164e63;
  --cf-accent: #f59e0b;
  --cf-card: #ffffff; /* component background */
  --cf-card-fg: #111827;
  --cf-border: #e5e7eb;
  --cf-muted: #f3f4f6;
  --cf-muted-fg: #6b7280;
  --cf-error: #b91c1c;
  --cf-success: #15803d;
  --cf-radius: 10px;
  --cf-font: inherit; /* body font; theme sets the tenant's font */
  --cf-font-heading: var(--cf-font);
}
```

Every element inside a component has a stable class (`cf-card`, `cf-btn`, `cf-btn--primary`,
`cf-input`, `cf-field`, `cf-heading`, `cf-thanks`, …) you may style further. Nothing in
`cf.css` touches elements outside the components.

## 7. Builder users: the same extensions as blocks

In **Admin → Website → Pages**, the block picker has a block for every component: **Account**,
**Form**, **Booking**, **Blog posts**, **Products**, **Support form**. Each block's settings are
the component's attributes (the Form block asks for the form's slug). A page saved with a
**Live chat**, **Newsletter** or **Help centre** block from before those extensions left the
platform (§5.6) keeps the block: it shows nothing on the site and the editor labels it for
removal.
The **Blog posts** block links each post to `<Post address>/<slug>` - `/blog/<slug>` unless its
**Post address** setting (`postBasePath`) names the page that lists the posts, e.g. `/news`
(1.3.1, blog 1.3.0). On a builder site every published post then has its own page there (and at
`/blog/<slug>`), inside the site's header, footer and theme, with the post's title, description and
image as its SEO; a page at the same address always wins, and drafts are a 404.
A block whose extension is not enabled is greyed out in the picker with the reason ("Enable
booking first"). Blocks stay editable in Pages - a page is never "locked" by the extension it
uses.

**Add-on extensions** (modules installed by platform staff from Global → Modules that run as
their own service, e.g. a field-service job request form) bring their own components, and the
**Extension component** block places one (1.3.0): pick the extension, pick one of its components,
add attributes as name/value pairs (the extension's guide page under Admin → Extensions lists what
its component accepts). The published page loads the component's script from the extension's own
address on your domain (`/m/<extension>/…`) - nothing from a third-party CDN, so the site's
security policy is unchanged - and shows nothing while the extension is switched off. Attribute
names are plain lowercase words; event handlers, styles and URLs cannot be set from the block.

The page editor (2026-09-22) shows every block in a **live preview** beside the block list: drag
blocks to reorder or drop them onto a section to nest, click a heading or button in the preview
to edit its text in place, undo with Ctrl+Z, and switch the preview between phone, tablet and
desktop widths. Extension blocks appear in the preview as a labelled placeholder - the live
component renders on the published page (and on "Preview draft"), where the SDK runs. The
**Extensions** tab of the "Add block" dialog lists them, and the **Templates** tab adds
ready-made groups (a pricing table, an FAQ, a team section, a contact section) in one click.

### 7.1 Start from a template

Builder sites can begin from an **approved template** - a complete site (pages, menus, a theme,
placeholder images) reviewed and bundled with the platform: agency, trades, restaurant, shop,
clinic, community/CID, portfolio and a one-page launch. Website → Overview offers "Start from a
template" while the site has no pages; Website → Pages has "Add pages from template" to pick
individual pages later; platform staff have "Install template" under System → Tenants.

What happens: every page lands as a **draft** and a **draft site version** is prepared - nothing
visitors see changes until you publish. Pages you already have are kept (the template's copy is
saved beside them, e.g. `/about-2`); menus only fill empty locations unless you ask to merge; the
template's colours and fonts are applied only when you tick "Apply the template's colours and
fonts" (that part is live at once, like every Design change). Templates that use an extension
(a booking form, a shop, a blog) say so - "Requires Booking" - and install
anyway; the block renders nothing until the extension is enabled under Extensions. Operators:
`GET /v1/templates` and `POST /v1/tenants/{id}/website/templates/{slug}/install` in the API
guide.

## 8. Deploying a static site

- **ZIP upload:** **Admin → Website → Files → Upload ZIP**, or
  `POST /v1/tenants/{id}/website/deploy` (multipart, `index.html` at the root or one folder
  down). Live at once as a new version; **Publish** shows every version and rolls back.
- **Single files:** `GET/PUT/DELETE /v1/tenants/{id}/website/files/{path}` for text files
  (HTML, CSS, JS, JSON, SVG, …, 2 MB each). Every write snapshots the site first.
- Any static tool works: plain HTML, Next.js `output: 'export'`, Astro, Hugo, 11ty. Map URLs to
  files as usual (`/about` → `about/index.html` or `about.html`).
- HTML is served `no-cache`; other assets one year immutable - use hashed file names for
  assets you change.

The operator recipes (read → edit locally → show the diff → write → verify → roll back) are in
the API guide, "Static and template sites: read, edit, redeploy".

## 9. Reserved paths

These prefixes are the platform's on every site and are never served from your files or pages:

`/api/*` · `/admin/*` · `/portal/*` · `/system/*` · `/global/*` · `/partner/*` · `/signin` ·
`/register` · `/access-denied` · `/vendor/*` (SDK, Tailwind, fonts) · `/logos/*` · `/websites/*` ·
`/m/*` (installed extensions that run as their own service - e.g. `/m/example/…`; their web
components load from `/m/<id>/public/…`, see the extension's own guide).

Routing shows them and refuses a rule that overlaps one.

## 10. For AI operators: a working method

1. Read this page and `GET /v1/claude.md`; confirm the tenant (name + slug) with the person.
2. `GET /v1/tenants/{id}/modules` - which extensions are on. Never enable one unasked; if the
   brief needs one that is off, say so and stop.
3. Decide static vs builder from `GET /v1/tenants/{id}/website` (`mode`). Static → edit files
   via the files API; builder → add blocks via the pages API (`{"type":"form","props":{"formSlug":"contact"}}`).
4. For "members only": create the Routing rule; do not write session checks into pages.
5. For a contact form: `<cf-form name="contact">` - no form needs creating first.
6. Verify on the live domain: the component's `data-cf-state` attribute is `ready` when it
   rendered, `disabled` when its extension is off, `error` when the request failed.
7. Report what you changed, whether it is published, and which extensions the page relies on.

## 11. FAQ

**Does this work on my own application behind a proxy rule?** Yes - the tags and `/vendor/cf.js`
work on any page served by your domain, including paths forwarded to your own app.

**Can I use the components on a site hosted elsewhere?** No. They call your site's own host
and rely on its session; there is no cross-origin mode. Host the site on CloudForge (or forward
the domain to CloudForge) and it works.

**The component shows nothing.** Open the browser console: `[cf] cf-form: the "forms" extension
is not enabled…` means the owner must switch it on under Admin → Extensions. No `[cf]` line at
all means the script tag is missing or the page is not on a CloudForge-served domain.

**Is the old `/api/public/contact` still there?** Yes, unchanged, for pages that post to it by
hand. New pages should use `<cf-form name="contact">`.

**Rate limits?** Reads 120/min, submissions 5/min -
per visitor address. A `429` carries `Retry-After`.

**Where is the old Website Development Guide?** Its content that still applies is in sections 8
and 9; the rest is superseded by this page. The old URL stays for one release with a pointer here.

---

_CloudForge - the Site SDK is `/vendor/cf.js` + `/vendor/cf.css`, built from `src/sdk/` in the
platform repository. Report problems with a page URL and the `[cf]` console line._
