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.

// 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

ThingWhere it livesNote
A pageAn artifact page in the projectAn ordinary page. Editing it elsewhere is fine
An image, font, data fileupload_page_asset on that page, referenced as assets/<name>Public by design
The databaseA table page in the same projectReachable only through a grant
The siteSite record, in the HypeSites tilePaths, head, domains, grants, status
What visitors seeA published SiteVersionFrozen HTML of every page
The public serverhypesites 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.

StatusVisitors get
draft404 — never published
liveThe published version
offline410 "not available" — the kill switch, effective within seconds

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.

// 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: '[email protected]', Name: 'Ann' });

A page with no JavaScript works too:

<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.

RuleValue
Rows per read1,000 max; a grant may name a view, whose filters and sorts then apply
Submissions10/min per visitor, 300/min per site
Cell valuesCoerced to the column's type; a bad one is refused naming the field; a select must match its options
Formula columnsReadable, never writable — an append grant naming one is refused
Table size10,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)

storeBehaviour
"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.

KeyDoesWho may set it
ticket{ project, assigneeBioId?, title? } — opens a ticket; {Column} placeholders in the title fill from the rowan editor who can file tickets in that project
notifyup to 10 addresses emailed per submissionany editor
webhook{ url, secretRef, enabled? } — HMAC-signed POST with retriesplatform admin
crmturns 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)

"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
  }
}
ModeWhat 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.
createUpserts 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:

# 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

// ❌ 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" }
// ❌ 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.

Last updated: October 6, 2026