Skip to content

Phase B.1: contact form (Worker + page + Datenschutz + diagnostic fixes)#6

Merged
larsweiser merged 12 commits into
mainfrom
dev
Apr 28, 2026
Merged

Phase B.1: contact form (Worker + page + Datenschutz + diagnostic fixes)#6
larsweiser merged 12 commits into
mainfrom
dev

Conversation

@larsweiser
Copy link
Copy Markdown
Collaborator

@larsweiser larsweiser commented Apr 28, 2026

Summary

Phase B.1 — contact form. Adds the first Worker route on the site (POST /api/contact) for the visitor-message → email pipeline; everything else stays on Workers Static Assets via env.ASSETS.fetch fall-through. End-to-end verified at G B.1.3a staging smoke.

Architecture

  • src/worker/index.ts + src/worker/contact.ts — narrow Worker routing POST /api/contact to a 7-step handler: form parse → honeypot drop → Workers Rate Limit → Turnstile siteverify → field validation → mimetext + cloudflare:email send → 303 redirect with locale-aware _returnTo allowlist.
  • src/components/forms/ContactForm.astro — visible form (name/email/message) with off-screen honeypot, hidden _returnTo, Turnstile widget (theme-bridged to site data-theme), pattern-validated email + localized tooltip, status region, sessionStorage draft preservation, per-field error highlighting.
  • src/pages/kontakt.astro + src/pages/en/contact.astro — page shells reading from the editorial collection + rendering the form.
  • src/content/editorial/contact.{de,en}.md — page intro (email + GitHub) + Art. 13 in-form notice (verbatim from BASELINE_COPY §6).
  • Nav: Contact entry added to SiteHeader + SiteFooter.
  • i18n: contactForm namespace in DE + EN.
  • wrangler.jsonc — per-env Worker bindings: main, send_email, ratelimit (via unsafe.bindings), assets.binding=ASSETS, assets.run_worker_first=["/api/*"], compatibility_flags=["nodejs_compat"], vars.TURNSTILE_SITE_KEY.
  • worker-configuration.d.ts + src/worker/env-augment.d.ts — generated Env types + manual augmentation for CONTACT_RATELIMIT and TURNSTILE_SECRET_KEY (wrangler-types lag).

Datenschutz delta

Committed earlier in this branch (e1da5cc). Covers §9.2 retraction (no-contact-forms removed), §9.4 Turnstile sub-paragraph, §9.5 paragraphs 2 + 3 (bidirectional Email Routing + form-submission flow), §9.8 challenges.cloudflare.com carve-out, §6 in-form Art. 13 notice. Drafted by a fresh-Claude.ai reviewer session per project rule §11.4 and approved verbatim.

Doc deltas

  • CLAUDE.md — "Process rules from Pass 1" gains a hybrid Workers + Static Assets posture bullet (Worker for /api/contact only; assets-only otherwise).
  • docs/TECH_STACK.md §10 — secrets table gains TURNSTILE_SECRET_KEY; new "Worker runtime vars" sub-table for TURNSTILE_SITE_KEY.

Diagnostic-driven fixes during G B.1.3a smoke

Each landed as its own commit for traceability. Surfaced loudly in case of staging-vs-production differences during G B.1.3b:

  • ratelimit field unrecognized by wrangler 4.85.0 → moved to unsafe.bindings with type: "ratelimit". CONTACT_RATELIMIT registers; Worker no longer 500s on the rate-limit .limit() call.
  • mimetext rejected bare-string Reply-To → wrapped the visitor's email in a Mailbox instance.
  • Browser-navigation POSTs hit static-asset 405 → added assets.run_worker_first: ["/api/*"] so /api/* always routes through the Worker before the static-asset matcher.
  • HTML5 type=email lenient (Safari accepted no-TLD strings) → added pattern="\S+@\S+\.\S+" + localized title. Caught a follow-up Astro string-literal backslash-stripping issue (the rendered HTML had S+@S+.S+); fixed via source-side \\S double-escape.
  • Server-side error returns wiped form data → sessionStorage bbl-contact-form-draft saves on submit, restores on ?error=* page load, clears on ?ok=1. Plus per-field .contact-form__field--error class so the user sees WHICH field failed (with :user-invalid CSS for live correction feedback).
  • Turnstile widget theme didn't follow site theme → inline script reads <html data-theme> and pre-sets the widget's data-theme before the Turnstile loader's async script can execute.

Test plan

  • G B.1.0 — IONOS DNS preserved (Cloudflare zone exported), .de Email Routing active, Turnstile widgets created (staging + production)
  • G B.1.1 — BASELINE Datenschutz delta committed (e1da5cc)
  • G B.1.2 — Worker scaffold + page + form + nav + docs reviewed
  • G B.1.3a — Staging smoke on dev.blackbrowedlabs.com:
    • Contact pages render (DE + EN)
    • cURL no-JS path → email arrives at lars@blackbrowedlabs.com
    • Honeypot silent-drop (no email)
    • Browser form submission → email arrives
    • Form data preserved on server-side error redirect
    • Per-field error highlighting on validation failure
    • Email pattern blocks Safari-lenient strings (e.g. mail@no-tld)
    • Email pattern accepts valid strings (e.g. mail@larsweiser.com)
    • Turnstile widget matches site theme (light + dark)
    • Language switcher (DE ↔ EN)
    • Mobile viewport (360 px)
    • Turnstile challenge interaction
  • build-pr CI green
  • G B.1.3b — Production smoke on blackbrowedlabs.com:
    • cURL no-JS path → email arrives in production
    • Browser form submission → email arrives
    • All other staging checks repeated against blackbrowedlabs.com

Known limitations / follow-ups (backlog rows queued)

  • Workers Rate Limit binding (CONTACT_RATELIMIT) registers but doesn't enforce on unsafe.bindings shape. Honeypot + Turnstile remain the active bot-defense layers. Revisit when Cloudflare promotes rate-limit to a stable env field.
  • Live theme-toggle on the contact page doesn't re-render the Turnstile widget (acceptable — page-load matches site theme; runtime toggle is a rare edge case).
  • Form base styles co-located in ContactForm.astro's scoped <style> block; backlog row feat(privacy): Datenschutz + Privacy pages (Phase B.3) #9 (refinement-needed) covers extraction to a shared design-system layer.

🤖 Generated with Claude Code

blackbrowed-labs and others added 12 commits April 28, 2026 11:28
Lands the legal-text changes required before the Phase B.1 contact form
ships. No code, no config — text-only edits to BASELINE_COPY.md.

§6 Contact page    — add Art. 13 in-form notice (DE + EN)
§9.2 / §10.2       — retract "no contact forms" sentence
§9.4 / §10.4       — broaden heading; add Turnstile sub-paragraph
§9.5 / §10.5 ¶2    — widen Email Routing disclosure inbound→bidirectional
§9.5 / §10.5 ¶3    — new form-submission paragraph (Worker → IONOS)
§9.8 / §10.8       — extend embed carve-out for challenges.cloudflare.com
§9.7 / §10.7       — verified unchanged (Pre-Clearance OFF, cookie-free)

Stand / Last updated lines deliberately not stamped — that happens on
the form-deploy commit, not this one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- src/worker/index.ts: entry router; POST /api/contact → dynamic
  import of ./contact handler, otherwise fall through to ASSETS.fetch.
- src/worker/contact.ts: seven-step handler — parse → honeypot drop
  → rate-limit → Turnstile siteverify (graceful no-JS path) →
  validation → mimetext + EmailMessage send via cloudflare:email →
  303 redirect with locale-aware _returnTo allowlist.
- src/worker/env-augment.d.ts: patches CONTACT_RATELIMIT (RateLimit)
  + TURNSTILE_SECRET_KEY (Worker secret) into the global Env until
  wrangler types emits them automatically.
- worker-configuration.d.ts: committed per ad-hoc team decision —
  reproducible types in CI without a build-time wrangler dep.
- wrangler.jsonc: per-env main, assets.binding=ASSETS, send_email,
  ratelimit (CONTACT_RATELIMIT), vars.TURNSTILE_SITE_KEY,
  compatibility_flags=[nodejs_compat]; leading comment refreshed to
  reflect the hybrid Workers + Static Assets posture.
- package.json: add mimetext@^3.0.28 runtime dep.

Worker is unverified at runtime until G B.1.3a staging smoke
(A12 §9 UNVERIFIED #1: cross-zone send_email).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- CLAUDE.md "Process rules from Pass 1": add hybrid Workers + Static
  Assets posture bullet so the standing rule (was "assets-only" implicit
  in wrangler.jsonc) explicitly acknowledges the one narrow Worker for
  /api/contact, and frames it as an exception not a new norm.
- TECH_STACK.md §10: add TURNSTILE_SECRET_KEY to the secrets table and
  add a small "Worker runtime vars" table for TURNSTILE_SITE_KEY (the
  matching public site key, declared per env in wrangler.jsonc vars).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- src/components/forms/ContactForm.astro: visible form (name/email/
  message), aria-required hooks, hidden honeypot at off-screen pos
  + aria-hidden, hidden _returnTo, Turnstile widget container +
  auto-loader script, status region with role=status / aria-live=
  polite populated by inline script reading ?ok=1 / ?error= URL
  params.
- src/content/editorial/contact.{de,en}.md: page intro (email +
  GitHub) + Art. 13 privacy notice rendered above the form. Notice
  copy from BASELINE_COPY §6 verbatim (committed in e1da5cc).
- src/pages/kontakt.astro + src/pages/en/contact.astro: page shells
  reading the editorial entry, rendering breadcrumb + H1 + body +
  ContactForm with locale + returnTo props.
- src/components/SiteHeader.astro + SiteFooter.astro: add Contact
  nav entry. Footer aria-label corrected to t.navAriaLabel (was
  t.about — a Pass 1 holdover).
- src/i18n/{de,en}.ts: contactForm namespace (18 keys: aria, fields,
  required-indicator pair, Turnstile attribution, privacy heading +
  link label, submit + busy, success, errors keyed snake_case to
  match A12 query-param dispatch).

Turnstile site keys embedded directly per env via PUBLIC_ENVIRONMENT
in the form component; mirrors wrangler.jsonc env.<env>.vars value
because Astro builds at build time and cannot read wrangler runtime
vars.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diagnostic chain at G B.1.3a smoke:
- POST /api/contact returned HTTP 500 with TypeError
  "Cannot read properties of undefined (reading 'limit')" at the
  env.CONTACT_RATELIMIT.limit() call.
- Deploy logs revealed wrangler 4.84.1 had emitted a warning we
  missed: "Unexpected fields found in env.staging field: 'ratelimit'".
  Wrangler ignored the field; the binding never registered; the
  generated Env type lacked it (which retroactively explained the
  earlier wrangler-types lag flagged in env-augment.d.ts).
- Bumped wrangler 4.84.1 → 4.85.0 (cheapest first try); 4.85.0 emits
  the same warning. Pivoted to the documented `unsafe.bindings`
  syntax with `type: "ratelimit"`, which 4.85.0 accepts. Dry-run
  now lists `env.CONTACT_RATELIMIT (ratelimit)` in the bindings.
- A12 §5.4 had cited the top-level `ratelimit: [...]` form; the
  Cloudflare schema has moved since the audit was authored. Comment
  block in wrangler.jsonc updated to document the pivot and a
  pointer to revisit when wrangler stabilises the field.

env-augment.d.ts already declared CONTACT_RATELIMIT manually; that
declaration remains correct and is now backed by an actual binding.
Worker code unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diagnostic at G B.1.3a smoke #2: rate-limit fix unblocked the path
to email-send, which then failed with
  MIMETEXT_INVALID_HEADER_VALUE: The value for the header
  "Reply-To" is invalid.

mimetext's setHeader signature accepts string | Mailbox | Mailbox[];
the bare email string fails its internal validation. Constructed
the Mailbox explicitly via `new Mailbox({ addr: email })`. Visitor
display name is intentionally omitted from the header to avoid
header-injection risk on user-supplied input; the name is rendered
in the body for context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diagnostic at G B.1.3a browser smoke: form submission via the browser
returned 405 with empty body (the "blank page"), even though the same
POST via cURL hit the Worker correctly and returned 303.

Reproduced by adding browser-like headers to cURL (Origin, Referer,
Sec-Fetch-Mode: navigate, Sec-Fetch-Dest: document) — same 405. Without
those headers — 303 from Worker. So Cloudflare's Workers Static Assets
treats browser navigations as "asset-first" by default and 405s POST
because static assets are GET-only; the Worker is bypassed entirely.

Fix: add `run_worker_first: ["/api/*"]` to each `assets` block (top-
level + per-env). Surgical: only the contact-form path runs through
the Worker; static assets continue to be served first for everything
else (the fast path that dominates this site's traffic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two-part UX fix for the data-loss surfaced at G B.1.3a browser smoke
(invalid email → server-side validation rejection → blank form on
return — user lost a long message to a typo):

A. Removed `novalidate` from the <form>. Browser-native validation
   now intercepts simple cases (empty required field, malformed
   email) before any submit happens; data stays in the form
   automatically because there's no submit/redirect round-trip.

B. Added sessionStorage draft preserve+restore. On submit, name /
   email / message values are saved to sessionStorage under
   `bbl-contact-form-draft`. On page load, if the URL has
   `?error=*`, the saved values are restored into the form
   fields. On `?ok=1` (successful submission), the draft is
   cleared. Honeypot, Turnstile token, and `_returnTo` are
   intentionally NOT preserved.

Together these handle every error path that loses data:
A catches client-validatable cases without a submit; B catches
server-side rejections (rate_limit, server, turnstile, any
validation case A misses).

Datenschutz: the new sessionStorage key (`bbl-contact-form-draft`)
is covered by the existing §9.7 / §10.7 broad carve-out for
technically-necessary Web Storage under § 25 Abs. 2 Nr. 2 TDDDG.
Naming the key explicitly (matching the `bbl-theme` precedent) is
a future enhancement; the broad carve-out is sufficient for now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per Lars's G B.1.3a feedback — when the Worker rejects (?error=*) the
user saw only a generic status message; no indication WHICH field was
the problem. The form repopulated (sessionStorage already wires that)
but the user was still left guessing.

D-of-A+B+C+D in flight (C — stricter email pattern — landing in a
follow-up commit so this can be tested first against the still-bad
Safari-accepted email path):

- Inline script: after restoring the draft, runs checkValidity()
  per field and adds .contact-form__field--error to invalid ones.
  A local Worker-mirroring email regex flags the Safari-lenient
  case (Safari's type=email accepts `mail@no-tld` which the
  Worker rejects).
- Live error-class management: the input's `invalid` event marks
  on blocked-submit; `input` events clear the class as the user
  corrects.
- CSS: red border via :user-invalid (modern pseudo-class — only
  fires after user interaction, so untouched required fields
  don't go red on first paint) AND via the explicit error class
  for server-error returns. Red label too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Browser-native type=email is lenient — Safari accepts strings without
a dot in the domain (e.g. mail@larsweisercom). Added an explicit
pattern attribute that mirrors the Worker's server-side regex
(\S+@\S+\.\S+) so the browser blocks the same shapes the Worker
rejects, before any submit / round-trip / data-loss potential.

The `title` attribute supplies a localised hint that surfaces in
the browser's native validation tooltip (Safari + Firefox + Chrome
all read it). New i18n key `contactForm.fields.emailFormatHint`
added in both locales, same composed-UI register as the existing
`errors.*` keys.

Backlog row #9 added (gitignored): extract form base styles to a
shared design-system layer; refinement-needed item — three
decisions (single file vs. split, class naming, scope) before
any code moves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Astro processes attribute values in .astro files as JavaScript string
literals. The previous source `pattern="\S+@\S+\.\S+"` rendered as
the broken HTML `pattern="S+@s+.S+"` because `\S` and `\.` aren't
valid JS escape sequences and the backslashes were stripped — every
real email got rejected (Lars's mail@larsweiser.com hit this).

Fix: source-side `\\S` and `\\.` so the runtime HTML carries the
intended `\S` and `\.` regex tokens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Turnstile's `data-theme="auto"` reads prefers-color-scheme (system),
not our site theme on <html>. When user has toggled the site to dark
on a light-system OS (or vice versa), the widget stayed
out-of-palette — Lars flagged it during dark-mode walkthrough on
G B.1.3a.

Fix: add a tiny synchronous inline script that runs at HTML parse
time (before the Turnstile loader's async script can execute) and
pre-sets `data-theme` on the .cf-turnstile element to match
<html data-theme>. BaseLayout's FOUC-prevention script has already
set the html attribute by then. Turnstile's auto-render then reads
the correct value.

Trade-off accepted: a runtime theme toggle on the contact page
won't re-render the widget (Turnstile reads data-theme once on
render, not as a live attribute). Acceptable — the bug Lars flagged
is the page-load mismatch, not a runtime-toggle case. If the latter
matters later, switch to explicit turnstile.render API and
MutationObserver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@larsweiser larsweiser merged commit dc9171d into main Apr 28, 2026
2 checks passed
blackbrowed-labs added a commit that referenced this pull request Apr 28, 2026
Initially set to `enabled` at Pass 1 branch-protection config. Three
sequential dev→main PRs (Phase M #4, Phase 404 #5, Phase B.1 #6)
each tripped on the same friction: GitHub's rebase-merge rewrites
SHAs, leaving dev's history "stale" against main's new SHAs. Each
time required a manual realignment — temp-disable protect-dev's
force-push block, reset dev to main, force-push, re-enable. Three
toggle dances in two days.

Allowing merge commits on main removes the rewrite, preserving
original SHAs across both branches. Future dev→main PRs use
"Create a merge commit" (now the GitHub default since linear
history is off). dev and main never drift.

Trade-off: main's git log gains one merge commit per release.
`git log --first-parent main` gives the linear release-only view.
Squash and rebase merges remain available but are not the default.

Documents the change in §8.4 with the full reasoning chain so the
next contributor (or future Lars) understands why merge commits
sit alongside otherwise-strict main protection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
blackbrowed-labs added a commit that referenced this pull request Apr 29, 2026
Adds FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true to deploy-staging.yml and
deploy-production.yml at workflow level. ci-pr.yml already had it. Action
audit completed; actions/checkout and actions/setup-node bumped from @v4
to @v6 (first Node-24-native major is v5; v6 is the current latest).
cloudflare/wrangler-action stays at @V3 (no new major; v3.15.0 latest).
Closes backlog #6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
blackbrowed-labs added a commit that referenced this pull request Apr 29, 2026
…failures" design

Adds verify-cloudflare-facts.yml weekly cron workflow + scripts/checks/dpf.mjs
+ scripts/checks/cwa-retention.mjs + 6 fixtures + scripts/run-verifier.mjs
orchestrator. Extends scripts/check-cloudflare-facts-freshness.mjs to read
_meta.last_check_attempt with 30-day threshold (down from 90).

Migrates src/data/cloudflare-facts.json to verifier-era schema (schema_version
1; per-fact status + value fields; _meta block; structured cwa_retention with
integer month values; raw_events_retention_months explicitly null per
Cloudflare's documented absence). Updates src/lib/cloudflare-facts.ts type +
adds getEffectiveVerifiedDate helper (worst-case freshness signal: older of
the two per-fact verified_at). Privacy pages (datenschutz.astro, en/privacy.astro)
read via the helper.

Verifier shape per plans/active/pass-2/g-d-2/spec.md: 1/5/15-min retry budget
(21 min total, fits 30-min workflow timeout); status-return error handling
(no throws cross check boundaries); explicit registry per spec §9.6;
hand-written validator per §9.7; status-enum rename ('ok' CheckResult →
'active' JSON) per §6.2; v1-coverage smoke step inside verifier workflow only,
mode #8 deploy-triggered limitation acknowledged per §3.8.

All synthetic failure modes (#2, #4, #5, #6) verified via MOCK_SCENARIO=
fixture routing. Mode #1 (freshness gate) verified via stale last_check_attempt.
Modes #3, #7, #8, #9 deferred to controller-side post-commit per prompt's
"may defer" guidance. Outcome record at
plans/active/pass-2/g-d-7/verifier-test-matrix.md (workspace, gitignored).

Closes backlog #8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
blackbrowed-labs added a commit that referenced this pull request Apr 29, 2026
MAJOR 1: Workflow Issue heredoc malformed — replaced cat <<EOF with
printf '%s\n' array. Avoids the YAML-block-scalar / bash-heredoc
indentation interaction the reviewer flagged. Body and EOF placement
are no longer load-bearing; YAML stays valid; result is plain markdown.

MAJOR 2 (highest impact): Mode #6 semantics broken. Spec §2.6 / §3.6
/ §4.6 require BOTH an Issue AND an auto-PR on status='changed'.
Refactored the orchestrator's per-fact update routing per spec §1.3:
  - 'ok'      → advance verified_at + last_known_good_at + value fields
  - 'changed' → advance verified_at + value fields; pin last_known_good_at
  - other     → leave all pinned
The orchestrator now always exits 0 (unless it threw) and surfaces two
GH-output flags: has_diff (drives auto-PR) and has_attention (drives
Issue). Both can fire on the same run. Workflow gating updated to
match. Mode #6 dry-run now logs "would open auto-PR AND open
verifier-alert Issue" with the new value persisted to JSON.

MAJOR 3: Smoke step ISO vs locale-formatted date. Privacy pages now
emit hidden <meta name="bbl-verified-at" content="YYYY-MM-DD"> markers
(plus per-fact -dpf / -cwa variants) via a new <slot name="head" /> in
BaseLayout. Smoke step greps for the locale-independent marker
instead of the rendered "29. April 2026" / "April 29, 2026" text.

MINOR 1: Stale-escalation comments now carry a hidden marker
(<!-- verifier-stale-Nd -->); the post step skips when the marker is
already present on the target. Six-week-stale Issues no longer
accumulate weekly duplicate comments.

MINOR 2: Mode #7 (auto-PR staleness) coverage. Path chosen: extend
the step. Stale-escalation now scans both `gh issue list` and
`gh pr list --base dev` filtered by `headRefName startswith
"verifier/"`, with the same 14/30/60d cadence and marker idempotency.

MINOR 3: Smoke step `node -e` JSON parse now Date.parse()-validates
each verified_at and exits 1 with a named error if either is
unparseable, instead of silently emitting `undefined.slice(0,10)`.

MINOR 4: formatVerifiedDate throws on empty/non-string input rather
than rendering "Invalid Date" silently.

Workspace test matrix updated with new mode #6 + mode #7 outcomes,
the rationale block at the top of the document, and post-fixup
synthetic-test outputs.

Verification:
- npm run check: 0 errors / 0 warnings / 1 unrelated hint.
- PUBLIC_ENVIRONMENT=staging npm run build: 17 pages clean.
- Synthetic scenarios (5 modes): all pass; mode #6 now correctly
  advances aggregated_retention_months 6→12 + verified_at, pins
  last_known_good_at, and would fire both PR + Issue on real runs.
- bbl-verified-at markers present in dist/{datenschutz,en/privacy}/index.html;
  absent from non-privacy pages.
- python3 yaml.safe_load: workflow YAML parses clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants