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.
| 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.
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 |
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.
| 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.
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.stored row is final: Retry ignores it. onSubmit actions (ticket, notify, webhook, crm)
run exactly as for a table grant.stored rows. A copy refuses, with the
count, if it would pass 10,000 rows, and a row is never copied twice.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.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)"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. |
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.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.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.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:
localStorage, sessionStorage or cookies. Keep per-visitor state in memory, or
in the URL.brand.insureco.io are allowed, and page assets are proxied with CORS.Write links as if the site owned its domain (href="/pricing"). Under
hypesites.tawa.pro/<slug>/ they are rewritten for you.
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.
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).
// ❌ 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
/. Publishing is refused: a site needs a home page.HypeSite.data() in the portal preview. The helper exists only on the
published site; guard with if (window.HypeSite).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.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