Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion packages/sitetile/astro/content/forms.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
sitetile-page: forms
title: Form coverage — wired to an inbox, and deliberately not
title: Form coverage — wired to an inbox, wired to the reef, and deliberately not
lang: en-US
---

Expand All @@ -22,6 +22,19 @@ action in that app. See the note in Form.astro.
- Something else
### Tell us more {textarea}

## Wired to the reef inbox
%% sitetile: form action=inbox submit="Send it" %%
The one special `action=` value: `inbox` posts to this site's own same-origin
forwarder (`/__reef/inbox`, relayed by the site's own `_worker.js`) — no
cross-origin CSRF wall to hit. `method=` is ignored; the forwarder only accepts
POST. `Text` below is deliberately a field label that collides with one of the
forwarder's reserved wire names.

### Your name
### Email {email}
### Text
### Tell us more {textarea}

## Not wired to anything
%% sitetile: form submit="Send it" %%
🩸 The same coral with nowhere to send. Before 2026-08-15 this rendered a LIVE
Expand Down
47 changes: 45 additions & 2 deletions packages/sitetile/astro/smoke-build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ const ALLOWED_INLINE = [
// Signatures are STRING LITERALS, not identifiers — minification renames locals every build.
['signet locale banner (Lingo suggest-your-language)', 'signet-locale-banner-dismiss'],
['language chooser (/language route)', '[data-language-link]'],
// form: action=inbox only. Progressive enhancement over a real POST/GET-less static
// build — the coral cannot read `?inbox=` at build time, so a tiny script toggles the
// pre-rendered (server-hidden) status line after a real submit redirects back here. A
// no-JS visitor still gets a working <form method="post" action="/__reef/inbox">; they
// just don't see the thank-you/error line swap in.
['form inbox status (action=inbox only, self-gating on ?inbox=)', '[data-inbox-sent]'],
];
const ALLOWED_CHUNKS = [
['pagetile reader', 'ptr-mode:'],
Expand Down Expand Up @@ -380,8 +386,45 @@ const checks = [
// endpoint directly and 403s for every real visitor.
['form: no coral emits a cross-origin post to feelreef', () =>
!/action="https:\/\/feelreef\.com/.test(forms) && !/name="kind"/.test(forms)],
['form: still zero JavaScript — it has to work with scripts off', () =>
!/<script/i.test(forms.slice(forms.indexOf('<form'), forms.lastIndexOf('</form>')))],
// The fixture's 1st (escape-hatch) and 3rd (unwired, still last) forms carry no JS at
// all — sliced individually now that the 2nd form (action=inbox) legitimately does.
// Each slice is bounded to its OWN `</form>` — an open-ended slice on the last form
// would also swallow the page's trailing header-overlay script, unrelated to this coral.
['form: the non-inbox forms are still zero-JS — they have to work with scripts off', () => {
const first = forms.slice(forms.indexOf('<form class="st-form"'), forms.indexOf('</form>') + '</form>'.length);
const last = forms.slice(forms.lastIndexOf('<form class="st-form"'), forms.lastIndexOf('</form>') + '</form>'.length);
return !/<script/i.test(first) && !/<script/i.test(last);
}],
// -- action=inbox: the same-origin forwarder route (2026-09-03) --
['form: action=inbox rewrites to the same-origin forwarder, method forced to post', () =>
/<form class="st-form" action="\/__reef\/inbox" method="post">/.test(forms)],
['form: action=inbox emits return_to (this page\'s own path) + a honeypot, not display:none', () =>
/<input type="hidden" name="return_to" value="\/forms\/">/.test(forms)
&& /<input type="text" name="_hp" autocomplete="off" tabindex="-1" aria-hidden="true" style="[^"]*"/.test(forms)
&& !/name="_hp"[^>]*display:\s*none/.test(forms)],
['form: the {email} field is wired to visitor_email on the inbox route, not the general one', () => {
const general = forms.slice(0, forms.indexOf('Wired to the reef inbox'));
const inbox = forms.slice(forms.indexOf('Wired to the reef inbox'));
return /name="Email"/.test(general) && !/name="visitor_email"/.test(general)
&& /name="visitor_email"/.test(inbox);
}],
['form: a label colliding with a reserved inbox name is wire-prefixed, visible label untouched', () =>
/<label class="st-form-label" for="[^"]+">Text<\/label>/.test(forms)
&& /name="field_Text"/.test(forms)],
['form: inbox status lines render hidden by default, localized to the page lang (en-US)', () =>
/<p class="st-form-status st-form-status-sent" data-inbox-sent hidden>Thanks — your message is on its way\.<\/p>/.test(forms)
&& /<p class="st-form-status st-form-status-error" data-inbox-error hidden>Could not send — please try again\.<\/p>/.test(forms)],
// Not a page-wide script count (the header-overlay module ships on every page,
// unrelated to this coral) — just that the inbox status toggle itself appears
// exactly once, matching the fixture's one `action=inbox` section.
['form: the inbox status toggle script appears exactly once, matching the one action=inbox section', () =>
(forms.match(/\[data-inbox-sent\]/g) || []).length === 1],
['form: the non-inbox forms emit no return_to/honeypot (fixed contract, action=inbox only)', () => {
const general = forms.slice(0, forms.indexOf('Wired to the reef inbox'));
const unwired = forms.slice(forms.lastIndexOf('<form class="st-form"'));
return !/name="return_to"/.test(general) && !/name="_hp"/.test(general)
&& !/name="return_to"/.test(unwired) && !/name="_hp"/.test(unwired);
}],
// -- icons: the three well-known paths a browser, a crawler and iOS ask for unprompted --
['icons: all three well-known paths are emitted', () =>
['/favicon.ico', '/favicon.svg', '/apple-touch-icon.png'].every((p) => existsSync(distFile(p)))],
Expand Down
129 changes: 98 additions & 31 deletions packages/sitetile/astro/src/components/sections/Form.astro
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
// cutover-structure-before-data); with no action the submit button renders DISABLED — 🩸 it used
// to render live and post to the page's own URL, which on a static host is a 405 the visitor never
// sees (measured on sodaart, 2026-08-15). "Never claims a backend it lacks" was in this comment
// before it was in the markup. See the `inbox=` note below for a knob that was tried and withdrawn. Family posture: CommonMark, one `%% sitetile: %%` sentinel, native elements,
// before it was in the markup. `action=inbox` is the one special value — a fixed, wired route to
// feelreef's own same-origin inbox forwarder; see the note below. Family posture: CommonMark, one `%% sitetile: %%` sentinel, native elements,
// zero-JS, GAIDO-editable (fields live in the Markdown). A near-universal contact widget no other
// coral expresses — grown when a real /contact page hit the wall (recast doctrine: hit a wall,
// grow a GENERAL coral).
import { parseParams, inlineHtml, bodyHtml } from '@sitetile';
const { section, hero } = Astro.props;
import { uiCopy, DEFAULT_LANG } from '../../packages/lingo/locale.mjs';
const { section, hero, lang } = Astro.props;
const Title = hero && section.title ? 'h1' : 'h2'; // first section on the page → its title is the <h1>
const pm = parseParams(section.params);
// `eyebrow="…"` — the small label above the heading, the same seam every other
Expand All @@ -24,36 +26,66 @@ const submit = pm.submit || 'Send';
const fields = section.fields || [];
const idOf = (i) => 'st-form-f' + (i + 1);

// ── 🩸 `inbox=` IS NOT HERE, AND THIS IS WHY ─────────────────────────────────
// ── 🩸 `inbox=` IS BACK, 2026-09-03 — the same-origin route it needed exists now ──
//
// It shipped on 2026-08-15 and was withdrawn the same day, measured against
// production rather than reasoned about. The idea: one param that points the
// form at feelreef's `/api/inbox` and emits the routing fields, so a contact
// page reaches a real inbox with no JavaScript.
// It first shipped on 2026-08-15 and was withdrawn the same day: the idea then was
// one param pointing the form straight at feelreef's `/api/inbox`, cross-origin.
// That cannot work — feelreef is a SvelteKit app, and SvelteKit refuses any
// cross-origin POST whose content type is a FORM type, app-wide, before routing
// (`Cross-site POST form submissions are forbidden` — the same check that protects
// the dashboard's reply/claim actions, signup, checkout, the pay links). No enctype
// a browser sends for a plain `<form>` dodges it.
//
// It cannot work as a direct cross-origin post. feelreef is a SvelteKit app, and
// SvelteKit refuses any cross-origin POST whose content type is a FORM type —
// `Cross-site POST form submissions are forbidden`, app-wide, before routing.
// That check is not in the way of the feature; it IS the feature that protects
// every cookie-authenticated form action in that app (the dashboard's reply and
// claim actions, signup, checkout, the pay links). Turning it off to let this
// through would trade real protection for a convenience.
// The withdrawal note said the fix was a SAME-ORIGIN route: the form posts to the
// site's own path and the site's emitted `_worker.js` forwards it server-to-server.
// That forwarder now exists on the reef side, so this coral's half is just wiring
// `action=inbox` to it — a fixed, non-configurable contract:
//
// And an HTML form cannot dodge it: the three enctypes a browser will send are
// exactly the three that check matches. The bubble is unaffected because it
// posts `application/json`, which is not a form type — but a form cannot.
// • `action=inbox` → `<form action="/__reef/inbox" method="post">`. `method=` is
// ignored for this value — the forwarder only accepts POST.
// • Two hidden inputs the forwarder reads: `return_to` (this page's own path,
// locale-aware — Astro.url.pathname already carries the locale segment) and a
// honeypot `_hp` (visually hidden, NOT `display:none`, so a bot's autofill still
// reaches it — a display:none field is exactly what a scripted filler is taught
// to skip; kept out of the tab order and off-screen instead).
// • No `kind`/`id` param here — the forwarder bakes the site key server-side. This
// coral still carries only STRUCTURE, per cutover-structure-before-data.
// • Field naming: the `{email}` field is posted as `visitor_email` (the forwarder's
// one semantic field — it composes every other field into the message body under
// its own visible label). A label that collides with one of the forwarder's
// reserved names gets `field_` prefixed on the WIRE name only — the visible
// `<label>` text an author wrote never changes.
//
// ⇒ The route that WOULD work is a same-origin one: the form posts to the site's
// OWN path and the site's emitted `_worker.js` forwards it server-to-server.
// That is a build-pipeline change, not a coral change, so it is not smuggled in
// here. Until it exists, `action=` remains the escape hatch and an unwired form
// renders a disabled button rather than a live one that 403s.
//
// 🔴 Do not re-add a version of this that posts straight to feelreef.com. It
// will look correct in every test that calls the endpoint directly, and fail for
// every real visitor.
// 🔴 Do not re-add a version of this that posts straight to feelreef.com. It will
// look correct in every test that calls the endpoint directly, and fail for every
// real visitor — that is the exact failure this route exists to avoid.
const action = pm.action || undefined;
const method = (pm.method || 'post').toLowerCase();
const isInbox = action === 'inbox';
const method = isInbox ? 'post' : (pm.method || 'post').toLowerCase();
const formAction = isInbox ? '/__reef/inbox' : action;

// The forwarder's reserved field names — an owner field must never land on the wire
// under one of these, or it would be read as routing data instead of message content.
// Checked case-insensitively (the forwarder's own keys are all lowercase) so a field
// authored as "Kind" or "Text" cannot collide either. The visible label is untouched;
// only the wire `name=` gets `field_` prefixed.
const RESERVED_INBOX_NAMES = new Set(['kind', 'id', 'text', 'conversation_id', 'page_url', 'return_to', '_hp', 'visitor_email', 'email_field']);
const inboxNameFor = (label, kind) => {
if (kind === 'email') return 'visitor_email';
const s = String(label);
return RESERVED_INBOX_NAMES.has(s.toLowerCase()) ? `field_${s}` : s;
};
const nameFor = (f) => (isInbox ? inboxNameFor(f.label, f.kind) : f.label);
// `return_to` — locale-aware because Astro.url.pathname IS the built path, which
// already carries the locale segment for every non-default-locale page (the router
// bakes it in via toUrlLocale; see lib routing). Read once, per page render.
const returnTo = Astro.url.pathname;

// Client-only status line: the build is static, so the coral cannot read `?inbox=`
// at build time — only a visitor's browser can, after a real submit redirects back
// here. Kept tiny and inline (no import, no event handler attributes) like
// ArchiveView's tag-filter script, so it stays CSP-clean under a default script-src.
const copy = isInbox ? uiCopy(lang || DEFAULT_LANG) : null;

// 🔴 A form with nowhere to send is not a form with a working button. Before
// this, an unwired coral rendered a live submit that posted to the page's own
Expand All @@ -68,23 +100,58 @@ const wired = Boolean(action);
{section.title && <Title set:html={inlineHtml(section.title)} />}
{section.body && <Fragment set:html={bodyHtml(section.body)} />}
</div>
<form class="st-form" action={action} method={method}>
{isInbox && (
<p class="st-form-status st-form-status-sent" data-inbox-sent hidden set:html={inlineHtml(copy.inboxSent)} />
)}
{isInbox && (
<p class="st-form-status st-form-status-error" data-inbox-error hidden set:html={inlineHtml(copy.inboxError)} />
)}
<form class="st-form" action={formAction} method={method}>
{isInbox && (
<input type="hidden" name="return_to" value={returnTo} />
)}
{isInbox && (
// Honeypot: a real visitor never sees or tabs to this, so it stays empty; a
// bot's autofill routine — which reads the DOM, not computed CSS — still finds
// and fills it. Off-screen + zero-size, deliberately NOT `display:none`.
<input type="text" name="_hp" autocomplete="off" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;" />
)}
{fields.map((f, i) => (
<div class="st-form-field">
<label class="st-form-label" for={idOf(i)} set:html={inlineHtml(f.label)} />
{f.kind === 'textarea' ? (
<textarea class="st-form-input st-form-textarea" id={idOf(i)} name={f.label} rows="5"></textarea>
<textarea class="st-form-input st-form-textarea" id={idOf(i)} name={nameFor(f)} rows="5"></textarea>
) : f.kind === 'select' ? (
<select class="st-form-input st-form-select" id={idOf(i)} name={f.label}>
<select class="st-form-input st-form-select" id={idOf(i)} name={nameFor(f)}>
<option value="" disabled selected>—</option>
{(f.options || []).map((o) => <option value={o}>{o}</option>)}
</select>
) : (
<input class="st-form-input" id={idOf(i)} name={f.label}
<input class="st-form-input" id={idOf(i)} name={nameFor(f)}
type={f.kind === 'email' ? 'email' : f.kind === 'tel' ? 'tel' : 'text'} />
)}
</div>
))}
<button class="st-form-submit" type="submit" disabled={!wired}>{submit}</button>
</form>
{isInbox && (
<script is:inline>
(() => {
const root = document.currentScript.closest('.st-form-section');
if (!root) return;
const v = new URLSearchParams(location.search).get('inbox');
if (!v) return;
const form = root.querySelector('.st-form');
if (v === 'sent') {
const sent = root.querySelector('[data-inbox-sent]');
if (sent) sent.hidden = false;
if (form) form.hidden = true;
} else {
const err = root.querySelector('[data-inbox-error]');
if (err) err.hidden = false;
}
})();
</script>
)}
</section>
4 changes: 4 additions & 0 deletions packages/sitetile/astro/src/packages/lingo/locale.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -156,27 +156,31 @@ const LINGO_UI = {
noMatches: 'No matching posts', clearFilter: '\u2715 Clear filter', monthShort: (n) => (EN_MONTHS[Number(n) - 1] || String(n)).slice(0, 3),
ageBack: 'Go back', ageConfirm: 'Yes',
notFoundTitle: 'Page not found', notFoundBody: "That address doesn't exist on this site.", notFoundHome: 'Go to the homepage',
inboxSent: "Thanks — your message is on its way.", inboxError: 'Could not send — please try again.',
month: (n) => EN_MONTHS[Number(n) - 1] || String(n), countWrap: [' (', ')'] },
'zh-TW': { recent: '最新文章', tags: '標籤雲', archive: '所有貼文', search: '搜尋......',
tagPrefix: '顯示具有以下標籤的文章:', datePrefix: '發表於:',
authorPrefix: '作者:', categoryPrefix: '分類:', pageSuffix: (n) => `(第 ${n} 頁)`,
noMatches: '沒有符合的文章', clearFilter: '\u2715 清除篩選', monthShort: (n) => `${Number(n)}月`,
ageBack: '返回', ageConfirm: '是',
notFoundTitle: '找不到頁面', notFoundBody: '這個網站上沒有這個網址。', notFoundHome: '回到首頁',
inboxSent: '訊息已送出,謝謝。', inboxError: '傳送失敗,請再試一次。',
month: (n) => `${Number(n)}月`, countWrap: ['(', ')'] },
'ja-JP': { recent: '最新の記事', tags: 'タグクラウド', archive: 'すべての記事', search: '検索......',
tagPrefix: 'タグ:', datePrefix: '投稿日:',
authorPrefix: '投稿者:', categoryPrefix: 'カテゴリー:', pageSuffix: (n) => `(${n}ページ目)`,
noMatches: '一致する記事はありません', clearFilter: '\u2715 絞り込みを解除', monthShort: (n) => `${Number(n)}月`,
ageBack: '戻る', ageConfirm: 'はい',
notFoundTitle: 'ページが見つかりません', notFoundBody: 'このサイトにそのアドレスはありません。', notFoundHome: 'ホームへ',
inboxSent: '送信しました。ありがとうございます。', inboxError: '送信できませんでした。もう一度お試しください。',
month: (n) => `${Number(n)}月`, countWrap: ['(', ')'] },
'ko-KR': { recent: '최근 글', tags: '태그 클라우드', archive: '모든 글', search: '검색...',
tagPrefix: '태그: ', datePrefix: '게시일: ',
authorPrefix: '작성자: ', categoryPrefix: '카테고리: ', pageSuffix: (n) => ` (${n}페이지)`,
noMatches: '일치하는 글이 없습니다', clearFilter: '\u2715 필터 해제', monthShort: (n) => `${Number(n)}월`,
ageBack: '돌아가기', ageConfirm: '예',
notFoundTitle: '페이지를 찾을 수 없습니다', notFoundBody: '이 사이트에 해당 주소가 없습니다.', notFoundHome: '홈으로',
inboxSent: '메시지가 전송되었습니다. 감사합니다.', inboxError: '전송하지 못했습니다. 다시 시도해 주세요.',
month: (n) => `${Number(n)}월`, countWrap: [' (', ')'] },
};
/**
Expand Down
Loading