Phase B.1: contact form (Worker + page + Datenschutz + diagnostic fixes)#6
Merged
Conversation
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>
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>
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 viaenv.ASSETS.fetchfall-through. End-to-end verified at G B.1.3a staging smoke.Architecture
src/worker/index.ts+src/worker/contact.ts— narrow Worker routingPOST /api/contactto a 7-step handler: form parse → honeypot drop → Workers Rate Limit → Turnstile siteverify → field validation → mimetext +cloudflare:emailsend → 303 redirect with locale-aware_returnToallowlist.src/components/forms/ContactForm.astro— visible form (name/email/message) with off-screen honeypot, hidden_returnTo, Turnstile widget (theme-bridged to sitedata-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).SiteHeader+SiteFooter.contactFormnamespace in DE + EN.wrangler.jsonc— per-env Worker bindings:main,send_email,ratelimit(viaunsafe.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— generatedEnvtypes + manual augmentation forCONTACT_RATELIMITandTURNSTILE_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.8challenges.cloudflare.comcarve-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/contactonly; assets-only otherwise).docs/TECH_STACK.md§10 — secrets table gainsTURNSTILE_SECRET_KEY; new "Worker runtime vars" sub-table forTURNSTILE_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:
ratelimitfield unrecognized by wrangler 4.85.0 → moved tounsafe.bindingswithtype: "ratelimit".CONTACT_RATELIMITregisters; Worker no longer 500s on the rate-limit.limit()call.Mailboxinstance.assets.run_worker_first: ["/api/*"]so/api/*always routes through the Worker before the static-asset matcher.pattern="\S+@\S+\.\S+"+ localizedtitle. Caught a follow-up Astro string-literal backslash-stripping issue (the rendered HTML hadS+@S+.S+); fixed via source-side\\Sdouble-escape.bbl-contact-form-draftsaves on submit, restores on?error=*page load, clears on?ok=1. Plus per-field.contact-form__field--errorclass so the user sees WHICH field failed (with:user-invalidCSS for live correction feedback).<html data-theme>and pre-sets the widget'sdata-themebefore the Turnstile loader's async script can execute.Test plan
.deEmail Routing active, Turnstile widgets created (staging + production)e1da5cc)dev.blackbrowedlabs.com:lars@blackbrowedlabs.commail@no-tld)mail@larsweiser.com)build-prCI greenblackbrowedlabs.com:blackbrowedlabs.comKnown limitations / follow-ups (backlog rows queued)
CONTACT_RATELIMIT) registers but doesn't enforce onunsafe.bindingsshape. Honeypot + Turnstile remain the active bot-defense layers. Revisit when Cloudflare promotes rate-limit to a stable env field.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