# HypeSites — public sites from portal pages and tables

## The Short Version

A **HypeSite** publishes a project's **artifact pages** in iec-portal as a real public
website, with that project's **tables** as its database. Build the pages, group them into
a site, publish.

```jsonc
// 1. a page per screen — an ORDINARY artifact page in the project
create_page { project: "marketing", title: "Acme Landing", type: "artifact", content: "<!doctype html>…" }

// 2. the site: which page sits at which path, and which table columns it may use
create_site {
  project: "marketing", name: "Acme Launch", slug: "acme",
  pages: [{ path: "/", page: "acme-landing" }, { path: "/pricing", page: "acme-pricing" }],
  data:  [{ alias: "waitlist", table: "leads", mode: "append", columns: ["Email", "Name"] }]
}

// 3. publish — nothing is public until this
publish_site { slug: "acme" }   // → https://hypesites.tawa.pro/acme/
```

Or do the same in the portal's **HypeSites** tile: *New site* → add pages → tick columns →
*Publish*. The tile is also where you restore an old version, take a site offline, manage
domains, and retry a submission that never reached its table.

## The pieces

| Thing | Where it lives | Note |
|-------|----------------|------|
| A page | An `artifact` page in the project | An ordinary page. Editing it elsewhere is fine |
| An image, font, data file | `upload_page_asset` on that page, referenced as `assets/<name>` | Public by design |
| The database | A `table` page in the same project | Reachable only through a grant |
| The site | `Site` record, in the HypeSites tile | Paths, head, domains, grants, status |
| What visitors see | A published `SiteVersion` | Frozen HTML of every page |
| The public server | `hypesites` service (repo `insureco/hypesites`) | Holds no data; reads the portal over JSON |

**Everything is scoped to one project.** A site can only mount pages, and only grant
tables, from the project it belongs to.

## Publishing freezes the pages

`publish_site` snapshots every mounted page **as it is at that moment**. Editing an
artifact afterwards changes nothing the public sees until the next publish. Each publish
is a numbered version, and any earlier version can be restored (that only moves a
pointer — nothing is rebuilt).

So: after changing a page, **publish again**, or the change is invisible.

| Status | Visitors get |
|--------|--------------|
| `draft` | 404 — never published |
| `live` | The published version |
| `offline` | 410 "not available" — the kill switch, effective within seconds |

## One header and footer for the whole site

Don't hand-write a menu bar into every page: they drift. Make one artifact for the header
and one for the footer, and name them on the site (iec-portal#1223):

```jsonc
update_site { slug: "acme", header: "acme-site-header", footer: "acme-site-footer" }   // null removes
publish_site { slug: "acme" }
```

Or in the tile: site → **Pages** → *Shared header and footer*.

- On publish, each artifact's `<style>`/stylesheet links and `<body>` are injected into every
  page and the not-found page: header right after `<body>`, footer before `</body>`. No wrapper,
  so `position: sticky` works.
- Links to the page being rendered get `aria-current="page"`; style them with `a[aria-current]`.
- Frozen at publish like the pages: change the header, then **publish again**.
- Scope its CSS with a class prefix (`.acme-nav …`) so it can't clash with a page's styles, and
  drop each page's own nav when you adopt it.

## Tables as the database

A site touches a table only through a **grant**, and a grant lists its columns one by one.
Nothing else in the table is reachable, and no query language is exposed to the public.

```js
// read grant  → rows keyed by COLUMN NAME, granted columns only
const rows = await HypeSite.data('features');   // [{ Feature: "…", Status: "Live" }]

// append grant → one row per call
await HypeSite.submit('waitlist', { Email: 'a@b.co', Name: 'Ann' });
```

A page with no JavaScript works too:

```html
<form method="post" action="/_hs/data/<slug>/waitlist">
  <input type="hidden" name="_next" value="/thanks">          <!-- a path on this site -->
  <input name="_hp" style="display:none" tabindex="-1" autocomplete="off">  <!-- honeypot -->
  <input name="Email" type="email" required>
</form>
```

The helper (`HypeSite`) is injected into every published page — do not add a script tag
for it, and do not call the portal's own API from a site.

| Rule | Value |
|------|-------|
| Rows per read | 1,000 max; a grant may name a view, whose filters and sorts then apply |
| Submissions | 10/min per visitor, 300/min per site |
| Cell values | Coerced to the column's type; a bad one is refused **naming the field**; a `select` must match its options |
| Formula columns | Readable, never writable — an append grant naming one is refused |
| Table size | 10,000 rows (`TABLE_LIMITS.maxRows`) — applies to `store: "table"` grants only |

**Every submission is stored before it goes near the table** (`SiteSubmission`), so a full
table or a deleted column holds a row rather than losing it. The site's settings page lists
them with a **Retry** button.

### Where an append grant keeps its rows (`store`)

| `store` | Behaviour |
|---------|-----------|
| `"table"` (default; what an absent field means) | Row is stored, then appended to the table. Subject to the 10,000-row cap |
| `"submissions"` | Row is kept **only** in `SiteSubmission`, status `stored`. The table is never written, so no row cap and no whole-body rewrite per submission |

- `store` is set on an append grant only (a read grant with one is refused). The grant still
  names a table and its columns: they define the field types, validation and the CSV header.
- A `stored` row is final: Retry ignores it. `onSubmit` actions (ticket, notify, webhook, crm)
  run exactly as for a table grant.
- The site's **Submissions** list pages through everything (filter by form, status, date),
  **exports CSV** for one form (columns by name, at most 50,000 rows per export), and can
  **copy into the table** a selection or range of `stored` rows. A copy refuses, with the
  count, if it would pass 10,000 rows, and a row is never copied twice.
- Through JCI, `create_site` / `update_site` take `store` on a `data` item; omitting it keeps
  the grant's current store. Existing grants change only when an editor switches them.

## Acting on a submission (`onSubmit`)

An append grant can do something with each row once it lands. Every action is optional and
runs **once per submission** — a Retry or a double call never repeats it.

| Key | Does | Who may set it |
|-----|------|----------------|
| `ticket` | `{ project, assigneeBioId?, title? }` — opens a ticket; `{Column}` placeholders in the title fill from the row | an editor who can file tickets in that project |
| `notify` | up to 10 addresses emailed per submission | any editor |
| `webhook` | `{ url, secretRef, enabled? }` — HMAC-signed POST with retries | platform admin |
| `crm` | turns the submission into a CRM lead (below) | any editor for `candidate`; platform admin for `create` |

In `update_site`, leaving `onSubmit` out of a `data` item keeps the action; `null` clears it.
The same goes for `crm` inside it.

### `onSubmit.crm` — leads into the CRM (iec-portal#942)

```jsonc
"onSubmit": {
  "ticket": { "project": "policyos", "title": "Waitlist — {Agency}" },
  "crm": {
    "emailColumn": "Work email",      // required — a granted column, by name or id
    "nameColumn": "Name",             // optional
    "companyColumn": "Agency",        // optional; else the email domain (never gmail.com & co)
    "sourceColumn": "Source page",    // optional; this is the default
    "dealName": "PolicyOS — {Agency}",// optional; default "<site name> — <company>"
    "stage": "qualification",         // qualification | proposal | negotiation
    "mode": "candidate"               // candidate (default) | create
  }
}
```

| Mode | What happens |
|------|--------------|
| `candidate` (default) | The lead is posted to jci-brain (`POST /api/entities/site-leads`) as a PERSON entity keyed on email. It shows at the **top** of `list_crm_candidates` with `site_lead.source_page`, despite the mention floor. Nothing reaches the portal CRM until someone promotes it. |
| `create` | Upserts the **Company** (exact name, then email domain), the **Contact** (email), and the **deal** (company + deal name). The deal carries `sourceRefs: [{ kind: "site", siteSlug, submissionId, rowId, sourcePage }]` — the UTM attribution. A free-mail lead with no company becomes an unfiled contact with no deal. |

- The crm step runs **before** the ticket, so the ticket's `clientEntityId` links to the CRM
  entity (the jci-brain person in `candidate`; the company's or contact's JCI entity in
  `create`, when it has one) and its description says what was recorded.
- The outcome is stored on the submission (`SiteSubmission.crm`: status, ids, note). A failed
  step — jci-brain down, say — shows a **CRM failed** badge, and **Retry** re-runs only that
  step and back-fills the ticket link. Every write is keyed, so a re-run cannot duplicate.
- Columns must be among the grant's columns; saving one that is not is refused by name.
- `create` writes CRM records from unverified public input. That is why it is admin-only and
  not the default: start with `candidate`, and switch once the form has proved clean.

## Pages are sandboxed on platform hosts

On `hypesites.tawa.pro` (and any `tawa.pro` / `insureco.io` / `daryx.com` /
`askjefferson.com` host) a site is served with the CSP `sandbox` directive and **no**
`allow-same-origin`, so one member's page can never touch the Bio-ID cookies living under
the same parent domain. Consequences for authors:

- **No `localStorage`, `sessionStorage` or cookies.** Keep per-visitor state in memory, or
  in the URL.
- Fonts, styles and scripts are fine: Google Fonts, cdnjs, jsdelivr and
  `brand.insureco.io` are allowed, and page assets are proxied with CORS.
- On a **custom domain** the sandbox is dropped, and storage works.

Write links as if the site owned its domain (`href="/pricing"`). Under
`hypesites.tawa.pro/<slug>/` they are rewritten for you.

## Custom domains

Attach the hostname to the **hypesites** service, then list it on the site:

```bash
# in the hypesites repo, once per domain
tawa domain add www.acme.com --cloudflare   # or --external / --direct
tawa domain verify www.acme.com
tawa deploy --prod
```

Then add `www.acme.com` under **Custom domains** on the site. It is then served at `/`,
unsandboxed, with no link rewriting.

## Who can do it

InsurEco staff **with edit access to the site's project**. JCI acts as a named person and
is held to the same access — it cannot build a site in a project that person could not
edit. Publishing, restoring and going offline each write a Septor event
(`site.published`, `site.rolled_back`, `site.status_changed`).

## What NOT to do

```jsonc
// ❌ create mode on a brand-new public form — every bot submission becomes a company + deal
"crm": { "emailColumn": "Email", "mode": "create" }
// ✅ start in the review queue
"crm": { "emailColumn": "Email" }
```

```jsonc
// ❌ editing a page and expecting the site to change
update_page { pageKey: "acme-landing", … }        // the live site still shows the old version
// ✅ publish again
publish_site { slug: "acme" }

// ❌ granting the whole table "to be safe"
data: [{ alias: "leads", table: "leads", mode: "read", columns: ["Email", "Phone", "Notes"] }]
// ✅ grant only what the page renders — a read grant is public data
data: [{ alias: "products", table: "catalog", mode: "read", columns: ["Name", "Price"] }]

// ❌ a form page built with create_form, then mounted on a site
//    A form is a portal surface for people who are asked to answer. A site form is an
//    append grant on an artifact page.

// ❌ localStorage in a site page — it throws on the platform host
// ❌ fetching the portal (or any other origin) from a site page — connect-src is 'self'
// ❌ a site page that needs a login — visitors are never signed in
```

## Common mistakes

- **No page at `/`.** Publishing is refused: a site needs a home page.
- **Expecting a draft to be visible.** A draft site 404s until its first publish.
- **Reading `HypeSite.data()` in the portal preview.** The helper exists only on the
  published site; guard with `if (window.HypeSite)`.
- **Inlining big images as data URIs.** A site's whole snapshot must stay under 12 MB —
  upload them as page assets instead.
- **Rotating `HYPESITES_KEY` on one side.** It is a shared secret between the portal and
  the hypesites service; set it on both and deploy both, or every page fails to load.

## Reference

Guide (screens, fields, code map): <https://portal.insureco.io/spaces/insureco/pages/hypesites-design> ·
Live example: <https://hypesites.tawa.pro/hypesites-demo/> ·
Code: `iec-portal/lib/sites/*`, `app/(modules)/hypesites/`, repo `insureco/hypesites` ·
Milestone: insureco/iec-portal#281.
