From 9435610b0f893bf4df9d1251d9fd614b68b8c831 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Thu, 20 Aug 2026 11:07:18 +0200 Subject: [PATCH 1/8] docs(join-form-flow): cover the /subscribe flow and its consent model The submit action names this document as the flow contract to keep in sync when its inputs or behaviour change, and #1031 changed both without touching it. Adds /subscribe as a third entry point, the create-versus-update axis the action now turns on, and a section on chapter sharing, which the two forms capture differently and which decides how the Airtable automations route a signup. Also corrects two statements that #1031 made false: that the basics are unconditionally required, and that OnboardingFlow has no initialEmail prop. --- docs/join-form-flow.md | 133 ++++++++++++++++++++++++++++++++++------- 1 file changed, 112 insertions(+), 21 deletions(-) diff --git a/docs/join-form-flow.md b/docs/join-form-flow.md index 8da711432..37fb1c54a 100644 --- a/docs/join-form-flow.md +++ b/docs/join-form-flow.md @@ -2,14 +2,18 @@ This document describes the flow of the PauseAI join / onboarding form, from the landing page through to the Airtable write (or stub capture) and optional -Substack subscription. +Substack subscription. It also covers `/subscribe`, the newsletter-only signup, +which is a different form with a different consent model but posts to the same +endpoint. ## Entry points and how they interact -The same `OnboardingFlow.svelte` component is mounted from two routes that wrap -it differently. Both routes share a single submit endpoint -(`/embed/onboarding-form?/submit`), so the server-side validation, Airtable -write, and stub capture logic live in exactly one place. +There are three entry points. Two mount the same `OnboardingFlow.svelte` +component with different wrappers; the third mounts `SubscribeFlow.svelte`, a +separate single-page form that can hand off to `OnboardingFlow` mid-flow. All +three share a single submit endpoint (`/embed/onboarding-form?/submit`), so the +server-side validation, Airtable write, and stub capture logic live in exactly +one place. ### Route 1 — `/join` (standalone page) @@ -35,11 +39,9 @@ Flow on `/join`: `initialLanguages` here — those are left at their defaults (empty / empty / `['English']`). Prefilling by geography is an embed-only feature (see below). -> Note: `join.md` passes `initialEmail={subscribeEmail}`, but the current -> `OnboardingFlow` props are `initialCountry`, `initialCity`, and -> `initialLanguages` only — there is no `initialEmail` prop yet. The email field -> in step 1 is therefore not prefilled from Collagen today. If you wire that -> prop up, update this section and the component's `Props` block together. +> Note: `OnboardingFlow` now does accept `initialEmail`, so the Collagen prefill +> described above works. The prop was added for the `/subscribe` hand-off (see +> Route 3), which needs to seed every basic field at once. ### Route 2 — `/embed/onboarding-form` (iframeable embed) @@ -69,6 +71,41 @@ The embed wrapper does four things the `/join` route does not: drops its `min-height: 100dvh` in embedded mode so the reported height can shrink as well as grow. +### Route 3 — `/subscribe` (newsletter-only signup) + +| File | Role | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `src/posts/subscribe.md` | Markdown post, `showTitle: false` so `SubscribeFlow` can own the heading. Reads `?subscribe-email=` and passes it as `initialEmail`. | +| `src/lib/components/onboarding/SubscribeFlow.svelte` | The single-page signup form, its thanks screen, and the hand-off into `OnboardingFlow`. | + +This route exists so someone who only wants the newsletter can finish in one +screen instead of walking the four-step join flow. It asks for the same four +basics (name, email, country, city) plus two opt-ins, and posts hidden +`subscribe_form=1`, `mode=contact`, `intent=Keep informed`, `keep_informed=on` +and `agree_gdpr=on`. + +`SubscribeFlow` is a three-phase machine rather than a step counter: + +- `form` — the signup itself. +- `thanks` — confirmation, with a "Get involved" button. +- `more` — renders `OnboardingFlow` seeded from the row that was just created, + so choosing to do more **updates** that record rather than creating a second + one. The seed is `startStep={2}`, `initialRecordId={recordId}`, + `initialKeepInformed={true}` and `initialChapterShare={fields.wantsChapter}`, + plus the four basics. + +Navigating to `/subscribe` while already on it resets the machine to `form` and +clears every field, so a second visitor on a shared device does not see the +previous person's details. + +**Entry points into this route.** The header carries a "Subscribe" item, and the +homepage box (`Home.svelte`) passes `handoffHref="/subscribe"` to +`NewsletterSignup`. With that prop set, `NewsletterSignup` stops posting to +Substack and instead navigates to `/subscribe?subscribe-email=…`, both in its +hydrated `goto()` and in its native form `action`, so the hand-off also works +without JavaScript. Note that `?subscribe-email=` is the same parameter the +Collagen banner on `/join` reads; the two uses are independent. + ### The shared submit endpoint Both routes' forms `POST` to the same action: @@ -90,7 +127,17 @@ The action returns `{ success: true, recordId }` on a create, or `OnboardingFlow` stores the returned `recordId` in component state and sends it back as a hidden `record_id` input on the step-3 volunteer form, so the volunteer details update the existing Airtable record instead of creating a -duplicate. +duplicate. `SubscribeFlow` does the same for its "Get involved" hand-off. + +**Create versus update is the axis most of the action's behaviour turns on**, so +it is worth stating once: a post carrying `record_id` is an update, and +everything else is a create. Updates skip the required-field check, never +rewrite `Signup source`, and only overwrite a basic field when the post supplies +a non-empty value, so a partial post cannot blank what the create collected. + +**Which form posted** is carried by `subscribe_form=1`. Only `/subscribe` sets +it, and the action uses it for exactly two decisions: which `Signup source` to +stamp on a create, and how to treat chapter sharing (below). ### Component overview @@ -119,7 +166,15 @@ It delegates rendering to a few child components and snippets: | `LinkWithoutIcon.svelte`, `Socials.svelte` | Footer links on confirmation screens. | | Snippets: `honeypotField`, `countrySelect`, `hiddenBasics`, `selectCards`, `checkboxConfirmations`, `gdprConsentField`, `nextStepBlock`, `confirmationFooter` | Reusable markup fragments shared across steps. | -On mount, the component fetches `GET /api/onboarding-mode` and logs whether the +`SubscribeFlow.svelte` is deliberately much smaller: one screen of fields, the +same honeypot and Turnstile protection, and the phase machine described under +Route 3. It shares `Turnstile.svelte`, `Combobox.svelte` and the options in +`options.ts` with `OnboardingFlow`, but not the step machine or the intent +cards. It keeps its fields in a single object so the same helper both +initialises them and resets them on same-page navigation, which stops the two +from drifting apart. + +On mount, both components fetch `GET /api/onboarding-mode` and log whether the form is live or stubbed to the browser console. This is needed because the pages embedding the form can be prerendered (e.g. `/join`), so the runtime env isn't available at render time. @@ -137,6 +192,9 @@ isn't available at render time. flowchart LR Join["/join
(join.md + CollagenSignup)"] --> Flow Embed["/embed/onboarding-form
(+page.svelte wrapper)"] --> Flow + Subscribe["/subscribe
(subscribe.md)"] --> SubFlow + SubFlow["SubscribeFlow.svelte
(phase machine)"] -- "POST ?/submit
subscribe_form=1" --> Action + SubFlow -- "Get involved
(seeded, startStep=2)" --> Flow Flow["OnboardingFlow.svelte
(step machine)"] -- "POST /embed/onboarding-form?/submit" --> Action["+page.server.ts
submit action"] Action -- "ONBOARDING_LIVE=true" --> Airtable["Airtable Members table"] Action -- "ONBOARDING_LIVE != true" --> Stub["recordStubSubmission()
/embed/onboarding-form/stub"] @@ -146,9 +204,8 @@ flowchart LR Stripe -- "success URL" --> Close["/close
(closes the tab)"] ``` -Both entry points converge on the same component and the same action, so -validation rules, field allowlists, and the live/stub switch only need to be -maintained in one place. +Every entry point converges on the same action, so validation rules, field +allowlists, and the live/stub switch only need to be maintained in one place. ## Step machine @@ -197,9 +254,14 @@ stateDiagram-v2 Target: base `appWPTGqZmUcs3NWu`, table `tblL1icZBhTV1gQ9o` ("Members"). -**Step 2 / browse signup (create):** `Full name`, `Email`, `Country`, `City`, -`Intent`, `Signup source`, `Email subscription` (keep_informed), -`Data privacy policy agreed`, `GDPR chapter share permission`. +**Step 2 / browse signup / subscribe form (create):** `Full name`, `Email`, +`Country`, `City`, `Intent`, `Signup source`, `Email subscription` +(keep_informed), `Data privacy policy agreed`, `GDPR chapter share permission`. + +`Signup source` is provenance, so it is written **once at create and never on an +update**: `June 2026 subscribe form` for `/subscribe`, `June 2026 onboarding +flow` otherwise. Without that rule the volunteer step, which carries no +subscribe marker, would rewrite a subscribe row as a join row. **Step 3 volunteer (update, only when `volunteer_details=on`):** adds `Discord Username`, `Phone`, `Languages`, `Other languages`, @@ -208,6 +270,30 @@ Target: base `appWPTGqZmUcs3NWu`, table `tblL1icZBhTV1gQ9o` ("Members"). `Projected weekly hours`, `Volunteer Agreement`, `Code of Conduct agreed`, and `Zip code` (US only). +### Chapter sharing + +`GDPR chapter share permission` records whether the person agreed to be +connected with their local PauseAI chapter. **The two forms capture that +agreement differently, which is the single most important difference between +them:** + +| | how it is captured | resulting value | +| ------------ | ------------------------------------------------------------------------------------- | --------------------- | +| `/join` | bundled into the required privacy checkbox, whose copy names chapter sharing outright | `true` on every row | +| `/subscribe` | a separate, optional "Also send me updates from my local chapter" tick | `true` only if ticked | + +On an update the signup-time choice is left alone, with one exception: an intent +of `Volunteer` or `Lead` sets it to `true`, because organising locally means +hearing from a chapter regardless. The `/subscribe` hand-off reposts the +signup-time choice on every update, so backing out of `Volunteer` restores the +original answer instead of leaving the escalation in place. + +This field is not only stored. The Airtable automations on the Members table +read it to decide whether a signup is handed to their national chapter's leader +or to the global onboarding address, and whether a US signup is copied into the +sheet shared with PauseAI US. Leaving it unticked is therefore a real routing +decision, not a preference flag. + ## Validation rules Enforced in the `submit` action before any write: @@ -223,12 +309,17 @@ Enforced in the `submit` action before any write: ### Field validation -- Required: `full_name`, `email`, `country`, `city`. +- Required **on a create**: `full_name`, `email`, `country`, `city`. An update + (the volunteer step, or the `/subscribe` hand-off) skips this check, so it + cannot re-demand what the create already collected. Requiring them on updates + broke the hand-off for anyone whose row was created with fewer fields. - `email` must match `^\S+@\S+\.\S+$`. - `country` must be in `COUNTRIES`. - `intent` must be one of `INTENTS` (`Act now` | `Volunteer` | `Lead` | `Keep informed`). -- GDPR consent (`agree_gdpr`) required **only on the create path** — step-3 - volunteer updates are exempt because consent was captured at step 2. +- GDPR consent (`agree_gdpr`) required **only on the create path**. Updates are + exempt because consent was captured when the record was created. `/subscribe` + posts it as a hidden field, since signing up on that form is itself the + privacy-policy consent, which its microcopy links. - Volunteer path additionally requires: ≥1 language, a valid `hours` value, and both `agree_volunteer` and `agree_conduct` checkboxes. From 9f9289a972672255db8bd5ecec016f25c73c4d94 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Thu, 20 Aug 2026 11:26:52 +0200 Subject: [PATCH 2/8] docs(join-form-flow): correct three claims and document continuation mode Review found the previous commit asserted things the code does not do. - Cut a sentence claiming the unconditional required-field check had broken the hand-off. Every create enforces all four fields, so no short row exists, and the hand-off reposts them anyway. The rule is robustness, not a fix. - Only OnboardingFlow logs the onboarding mode; SubscribeFlow fetches it to gate Turnstile and logs nothing. - The signup-time chapter choice is reposted by OnboardingFlow's step-2 form under isContinuation, not by every update, which the Signup source section already implied by noting the volunteer step carries no subscribe marker. Adds the constraint most likely to be broken by a future editor: Email, Intent, Email subscription and Data privacy policy agreed are written from the post on every call, so dropping the keep_informed hidden input would clear subscription flags silently. Also documents continuation mode, which changes the step machine the diagram describes, and drops the initialEmail note that now only narrates history. --- docs/join-form-flow.md | 68 +++++++++++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 17 deletions(-) diff --git a/docs/join-form-flow.md b/docs/join-form-flow.md index 37fb1c54a..f035db65c 100644 --- a/docs/join-form-flow.md +++ b/docs/join-form-flow.md @@ -39,16 +39,12 @@ Flow on `/join`: `initialLanguages` here — those are left at their defaults (empty / empty / `['English']`). Prefilling by geography is an embed-only feature (see below). -> Note: `OnboardingFlow` now does accept `initialEmail`, so the Collagen prefill -> described above works. The prop was added for the `/subscribe` hand-off (see -> Route 3), which needs to seed every basic field at once. - ### Route 2 — `/embed/onboarding-form` (iframeable embed) | File | Role | | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `src/routes/embed/onboarding-form/+page.svelte` | Thin wrapper around `OnboardingFlow`. Reads query params, sets the locale, applies the background color, and reports height to the host page. | -| `src/routes/embed/onboarding-form/+page.server.ts` | Houses the `submit` action shared by **both** routes (see "Submit endpoint" below). | +| `src/routes/embed/onboarding-form/+page.server.ts` | Houses the `submit` action shared by **all three** entry points (see "Submit endpoint" below). | | `src/routes/embed/onboarding-form/stub/+page.svelte` | Stub inspection page rendered when `ONBOARDING_LIVE` is not `true`. | | `src/routes/embed/onboarding-form/stub/+page.server.ts` | `load` function returning in-memory stub submissions. | @@ -108,7 +104,7 @@ Collagen banner on `/join` reads; the two uses are independent. ### The shared submit endpoint -Both routes' forms `POST` to the same action: +All three entry points `POST` to the same action: ``` action="/embed/onboarding-form?/submit" @@ -131,9 +127,18 @@ duplicate. `SubscribeFlow` does the same for its "Get involved" hand-off. **Create versus update is the axis most of the action's behaviour turns on**, so it is worth stating once: a post carrying `record_id` is an update, and -everything else is a create. Updates skip the required-field check, never -rewrite `Signup source`, and only overwrite a basic field when the post supplies -a non-empty value, so a partial post cannot blank what the create collected. +everything else is a create. Updates skip the required-field presence check, +never rewrite `Signup source`, and only overwrite `Full name`, `Country` and +`City` when the post supplies a non-empty value, so a partial post cannot blank +what the create collected. + +That guard covers those three fields and no others. `Email`, `Intent`, +`Email subscription` and `Data privacy policy agreed` are written from the post +on **every** call, updates included, so an update that omits `keep_informed` +writes `Email subscription: false`. The client reposts it from state today (a +hidden input on the step-2 form, guarded by `{#if keepInformed}`), which is the +only thing standing between a dropped hidden input and silently clearing +people's subscription flag with no other symptom. **Which form posted** is carried by `subscribe_form=1`. Only `/subscribe` sets it, and the action uses it for exactly two decisions: which `Signup source` to @@ -174,8 +179,9 @@ cards. It keeps its fields in a single object so the same helper both initialises them and resets them on same-page navigation, which stops the two from drifting apart. -On mount, both components fetch `GET /api/onboarding-mode` and log whether the -form is live or stubbed to the browser console. This is needed because the +On mount, both components fetch `GET /api/onboarding-mode`. `OnboardingFlow` +logs the answer to the browser console; both use it to decide whether the +Turnstile widget renders and whether a token is required to submit. This is needed because the pages embedding the form can be prerendered (e.g. `/join`), so the runtime env isn't available at render time. @@ -250,6 +256,27 @@ stateDiagram-v2 Browse --> [*] ``` +### Continuation mode + +The diagram above describes a fresh `/join` visit. When `OnboardingFlow` is +mounted by the `/subscribe` hand-off it runs in **continuation mode**, which +`isContinuation` derives from `startStep === 2 && !!initialRecordId`. The person +has already given their details and consent, so step 2 becomes a single +question, "what do you want to do", rather than a signup: + +- The keep-informed and Substack opt-in cards are hidden, as is the GDPR + consent checkbox, because both were answered on the subscribe form. +- Picking an intent becomes **required** to submit; on a fresh visit an opt-in + alone is enough. +- The stepper drops its first label and the Back button is gone, since there is + no step 1 to return to. +- The submit button reads "Continue" rather than "Submit", because the record + already exists and this post updates it. +- The two hidden inputs described under "Chapter sharing" are added. + +So the `Step2 --> Step1: Back` edge and the opt-in-only submit path in the +diagram do not exist in this mode. + ## Data written to Airtable Target: base `appWPTGqZmUcs3NWu`, table `tblL1icZBhTV1gQ9o` ("Members"). @@ -284,9 +311,13 @@ them:** On an update the signup-time choice is left alone, with one exception: an intent of `Volunteer` or `Lead` sets it to `true`, because organising locally means -hearing from a chapter regardless. The `/subscribe` hand-off reposts the -signup-time choice on every update, so backing out of `Volunteer` restores the -original answer instead of leaving the escalation in place. +hearing from a chapter regardless. So that backing out of `Volunteer` restores +the original answer rather than leaving the escalation in place, the hand-off +reposts the signup-time choice. Note where that lives: it is a pair of hidden +inputs on **`OnboardingFlow`'s step-2 form**, guarded by `isContinuation`, not +anything in `SubscribeFlow`. The step-3 volunteer form carries neither, which is +harmless only because it always posts `intent=Volunteer` and so takes the +escalation branch anyway. This field is not only stored. The Airtable automations on the Members table read it to decide whether a signup is handed to their national chapter's leader @@ -310,9 +341,12 @@ Enforced in the `submit` action before any write: ### Field validation - Required **on a create**: `full_name`, `email`, `country`, `city`. An update - (the volunteer step, or the `/subscribe` hand-off) skips this check, so it - cannot re-demand what the create already collected. Requiring them on updates - broke the hand-off for anyone whose row was created with fewer fields. + (the volunteer step, or the `/subscribe` hand-off) skips the presence check, + so it cannot re-demand what the create already collected. +- The email regex and the `intent` enum below still run on **every** post, + updates included, so an update carrying neither is rejected even though the + presence check was skipped. Only the country check was relaxed, to run when a + country is supplied. - `email` must match `^\S+@\S+\.\S+$`. - `country` must be in `COUNTRIES`. - `intent` must be one of `INTENTS` (`Act now` | `Volunteer` | `Lead` | `Keep informed`). From 4f7789f913fdc91066a76dd9440b04d4a562a018 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Thu, 20 Aug 2026 11:39:46 +0200 Subject: [PATCH 3/8] docs(join-form-flow): tighten claims a second review found overstated - The subscription-preservation warning named one hidden input; there are two, one on the step-2 intent form and one on the step-3 volunteer form, and dropping either clears the flag. Naming one left the other looking safe to remove. - Data privacy policy agreed is hard-coded true on every call, not taken from the post like the fields beside it. - The submit action is not the only route to a Substack subscription: a NewsletterSignup without handoffHref, as the Collagen banner uses, posts straight to Substack. - The result shape splits on live versus stub, not create versus update. - Turnstile hostname checking is weaker than stated: an absent hostname is accepted by design, and it compares hostname rather than full origin. - Adds Paying Interest to the volunteer update fields, drops an implementation detail that carried no constraint, and stops calling the join flow four-step when a newsletter-only signup finishes at step 3. Also points at where the Airtable automation behaviour is documented, since nothing in this repository can establish it. --- docs/join-form-flow.md | 58 +++++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/docs/join-form-flow.md b/docs/join-form-flow.md index f035db65c..a3e38bd01 100644 --- a/docs/join-form-flow.md +++ b/docs/join-form-flow.md @@ -75,7 +75,7 @@ The embed wrapper does four things the `/join` route does not: | `src/lib/components/onboarding/SubscribeFlow.svelte` | The single-page signup form, its thanks screen, and the hand-off into `OnboardingFlow`. | This route exists so someone who only wants the newsletter can finish in one -screen instead of walking the four-step join flow. It asks for the same four +screen instead of walking the multi-step join flow. It asks for the same four basics (name, email, country, city) plus two opt-ins, and posts hidden `subscribe_form=1`, `mode=contact`, `intent=Keep informed`, `keep_informed=on` and `agree_gdpr=on`. @@ -104,7 +104,7 @@ Collagen banner on `/join` reads; the two uses are independent. ### The shared submit endpoint -All three entry points `POST` to the same action: +All three entry points' onboarding forms `POST` to the same action: ``` action="/embed/onboarding-form?/submit" @@ -112,14 +112,19 @@ action="/embed/onboarding-form?/submit" This is intentional: the `submit` action in `src/routes/embed/onboarding-form/+page.server.ts` is the single source of -truth for validation, Airtable writes, Substack subscription, and stub capture. +truth for validation, Airtable writes, and stub capture. It is not the only +route to a Substack subscription: a `NewsletterSignup` rendered without +`handoffHref`, as the Collagen banner on `/join` does, posts straight to +Substack and never reaches this action. The `/join` route has **no** `+page.server.ts` with a `submit` action of its own — it relies entirely on the embed route's action. SvelteKit's form actions are addressed by URL, so a form rendered on `/join` can post to `/embed/onboarding-form?/submit` without any special wiring. -The action returns `{ success: true, recordId }` on a create, or -`{ success: true, recordId: existingRecordId, submission }` in stub mode. +The action returns `{ success: true, recordId }` in live mode and +`{ success: true, recordId, submission }` in stub mode. The split is live +versus stub, not create versus update: both branches return the same shape for +either. `OnboardingFlow` stores the returned `recordId` in component state and sends it back as a hidden `record_id` input on the step-3 volunteer form, so the volunteer details update the existing Airtable record instead of creating a @@ -132,13 +137,17 @@ never rewrite `Signup source`, and only overwrite `Full name`, `Country` and `City` when the post supplies a non-empty value, so a partial post cannot blank what the create collected. -That guard covers those three fields and no others. `Email`, `Intent`, -`Email subscription` and `Data privacy policy agreed` are written from the post -on **every** call, updates included, so an update that omits `keep_informed` -writes `Email subscription: false`. The client reposts it from state today (a -hidden input on the step-2 form, guarded by `{#if keepInformed}`), which is the -only thing standing between a dropped hidden input and silently clearing -people's subscription flag with no other symptom. +That guard covers those three fields and no others. `Email`, `Intent` and +`Email subscription` are taken from the post on **every** call, updates +included, and `Data privacy policy agreed` is hard-coded to `true` on every +call whether or not the post carries `agree_gdpr`. + +The consequence worth knowing: an update that omits `keep_informed` writes +`Email subscription: false`. Nothing on the server preserves it. What preserves +it is the client reposting it from state, through **two** separate +`{#if keepInformed}` hidden inputs in `OnboardingFlow`, one on the step-2 intent +form and one on the step-3 volunteer form. Dropping either one silently clears +that person's subscription flag, with no error and no other symptom. **Which form posted** is carried by `subscribe_form=1`. Only `/subscribe` sets it, and the action uses it for exactly two decisions: which `Signup source` to @@ -175,9 +184,7 @@ It delegates rendering to a few child components and snippets: same honeypot and Turnstile protection, and the phase machine described under Route 3. It shares `Turnstile.svelte`, `Combobox.svelte` and the options in `options.ts` with `OnboardingFlow`, but not the step machine or the intent -cards. It keeps its fields in a single object so the same helper both -initialises them and resets them on same-page navigation, which stops the two -from drifting apart. +cards. On mount, both components fetch `GET /api/onboarding-mode`. `OnboardingFlow` logs the answer to the browser console; both use it to decide whether the @@ -294,8 +301,8 @@ subscribe marker, would rewrite a subscribe row as a join row. `Discord Username`, `Phone`, `Languages`, `Other languages`, `Discovery method of PAI`, `Discovery method of PAI (Other)`, `Motivation`, `Motivation (Other)`, `Skills & Interests`, `Skill & Interests (Other)`, -`Projected weekly hours`, `Volunteer Agreement`, `Code of Conduct agreed`, and -`Zip code` (US only). +`Projected weekly hours`, `Volunteer Agreement`, `Code of Conduct agreed`, +`Paying Interest`, and `Zip code` (US only). ### Chapter sharing @@ -325,6 +332,11 @@ or to the global onboarding address, and whether a US signup is copied into the sheet shared with PauseAI US. Leaving it unticked is therefore a real routing decision, not a preference flag. +Those automations live in Airtable, not in this repository, so nothing here +proves that behaviour. They are documented in the `pauseai-civicrm` repository +under `notes/airtable-onboarder-automation-plan.md`, along with the field ids +each condition reads. + ## Validation rules Enforced in the `submit` action before any write: @@ -334,8 +346,8 @@ Enforced in the `submit` action before any write: - **Honeypot:** a non-empty `nickname` field (a client-side hidden field) silently returns success with no write — catches bots that render the page. - **Turnstile verification:** server-side CAPTCHA check via Cloudflare. The token - is validated against `TURNSTILE_SECRET_KEY`, the token's hostname is verified to - match the request origin, and test/invalid tokens are rejected. Bots that POST + is validated against `TURNSTILE_SECRET_KEY`, a hostname Turnstile reports is + checked against the request hostname, and test/invalid tokens are rejected. Bots that POST directly to the endpoint (bypassing the honeypot) are blocked here. ### Field validation @@ -370,8 +382,12 @@ The form implements a two-layer bot defense: The verification checks: - The `TURNSTILE_SECRET_KEY` is configured and is not a test key - The token is valid and successfully verified by Cloudflare - - The token's hostname matches the request origin (prevents token replay from - other origins like deploy previews) + - The hostname Turnstile reports for the token matches the request hostname, + which blocks a token minted on another origin (a deploy preview, say) and + replayed here. Note this is deliberately not a hard requirement: when + Turnstile reports no hostname the token is still accepted, so that a + missing field cannot lock out legitimate senders. It compares the hostname + only, not the full origin. - In development, verification is skipped if the secret is missing; in production, a missing or test secret causes the form to fail closed From 2789e65ec07d702902f3cd3ec724a293618c6157 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Thu, 20 Aug 2026 15:11:30 +0200 Subject: [PATCH 4/8] docs(join-form-flow): cut duplicated claims, and put the flagship hazard in the code A review of the document's direction, rather than of its accuracy, argued the unit to watch is claim surface: how many independently falsifiable statements need re-verifying whenever the action changes. This branch had been growing it. - Collects create-versus-update into one section. It was spread across the endpoint section, the Signup source paragraph and two validation bullets, so the next change to the action had three places to rot and a reviewer fixing one copy would miss the others. - Merges the two bot-protection sections, which had drifted into stating the Turnstile hostname check at two different strengths, and defers the mechanism to turnstile-verify.ts, which is commented in full. - Replaces the volunteer field inventory with a pointer to the block that writes it. That list is where a field went missing, and a second copy of a list is a sync liability rather than a tripwire. The create list stays: every field in it has a rule stated elsewhere, so it works as an index. - Drops the snippet inventory, the continuation-mode cosmetics, and the homepage plumbing narration, keeping the coupling that handoffHref carries. - Removes a pointer to a pauseai-civicrm file that is untracked and exists only on one machine, so no other reader could have followed it. Adds the comments the document cannot substitute for: the hazard about Email subscription being cleared is now stated at both hidden inputs in OnboardingFlow and at the field in the action, which is where someone deleting an apparently redundant input will actually be looking. --- docs/join-form-flow.md | 246 ++++++++---------- .../onboarding/OnboardingFlow.svelte | 6 + .../embed/onboarding-form/+page.server.ts | 2 + 3 files changed, 120 insertions(+), 134 deletions(-) diff --git a/docs/join-form-flow.md b/docs/join-form-flow.md index a3e38bd01..8451a0651 100644 --- a/docs/join-form-flow.md +++ b/docs/join-form-flow.md @@ -76,9 +76,10 @@ The embed wrapper does four things the `/join` route does not: This route exists so someone who only wants the newsletter can finish in one screen instead of walking the multi-step join flow. It asks for the same four -basics (name, email, country, city) plus two opt-ins, and posts hidden -`subscribe_form=1`, `mode=contact`, `intent=Keep informed`, `keep_informed=on` -and `agree_gdpr=on`. +basics (name, email, country, city) plus two opt-ins. Three of its hidden inputs +carry meaning: `subscribe_form=1` is the discriminator, `agree_gdpr=on` records +that signing up here is itself the consent, and `keep_informed=on` is posted +unconditionally, so every completed submission subscribes. `SubscribeFlow` is a three-phase machine rather than a step counter: @@ -94,13 +95,12 @@ Navigating to `/subscribe` while already on it resets the machine to `form` and clears every field, so a second visitor on a shared device does not see the previous person's details. -**Entry points into this route.** The header carries a "Subscribe" item, and the -homepage box (`Home.svelte`) passes `handoffHref="/subscribe"` to -`NewsletterSignup`. With that prop set, `NewsletterSignup` stops posting to -Substack and instead navigates to `/subscribe?subscribe-email=…`, both in its -hydrated `goto()` and in its native form `action`, so the hand-off also works -without JavaScript. Note that `?subscribe-email=` is the same parameter the -Collagen banner on `/join` reads; the two uses are independent. +**Entry points into this route.** `handoffHref` is the switch: given that prop, +`NewsletterSignup` stops posting to Substack and instead navigates to +`/subscribe?subscribe-email=…`, in both its hydrated `goto()` and its native +form `action`, so the hand-off survives without JavaScript. The homepage box +sets it; the Collagen banner on `/join` does not, which is why that one still +posts to Substack. `?subscribe-email=` therefore has two independent consumers. ### The shared submit endpoint @@ -112,46 +112,20 @@ action="/embed/onboarding-form?/submit" This is intentional: the `submit` action in `src/routes/embed/onboarding-form/+page.server.ts` is the single source of -truth for validation, Airtable writes, and stub capture. It is not the only -route to a Substack subscription: a `NewsletterSignup` rendered without -`handoffHref`, as the Collagen banner on `/join` does, posts straight to -Substack and never reaches this action. +truth for validation, Airtable writes, and stub capture. The one exception +worth knowing: a `NewsletterSignup` rendered without `handoffHref`, as the +Collagen banner on `/join` does, posts straight to Substack and never reaches +this action. + The `/join` route has **no** `+page.server.ts` with a `submit` action of its -own — it relies entirely on the embed route's action. SvelteKit's form actions -are addressed by URL, so a form rendered on `/join` can post to -`/embed/onboarding-form?/submit` without any special wiring. +own. SvelteKit's form actions are addressed by URL, so a form rendered on +`/join` can post to `/embed/onboarding-form?/submit` without any special +wiring. The action returns `{ success: true, recordId }` in live mode and -`{ success: true, recordId, submission }` in stub mode. The split is live -versus stub, not create versus update: both branches return the same shape for -either. -`OnboardingFlow` stores the returned `recordId` in component state and sends it -back as a hidden `record_id` input on the step-3 volunteer form, so the -volunteer details update the existing Airtable record instead of creating a -duplicate. `SubscribeFlow` does the same for its "Get involved" hand-off. - -**Create versus update is the axis most of the action's behaviour turns on**, so -it is worth stating once: a post carrying `record_id` is an update, and -everything else is a create. Updates skip the required-field presence check, -never rewrite `Signup source`, and only overwrite `Full name`, `Country` and -`City` when the post supplies a non-empty value, so a partial post cannot blank -what the create collected. - -That guard covers those three fields and no others. `Email`, `Intent` and -`Email subscription` are taken from the post on **every** call, updates -included, and `Data privacy policy agreed` is hard-coded to `true` on every -call whether or not the post carries `agree_gdpr`. - -The consequence worth knowing: an update that omits `keep_informed` writes -`Email subscription: false`. Nothing on the server preserves it. What preserves -it is the client reposting it from state, through **two** separate -`{#if keepInformed}` hidden inputs in `OnboardingFlow`, one on the step-2 intent -form and one on the step-3 volunteer form. Dropping either one silently clears -that person's subscription flag, with no error and no other symptom. - -**Which form posted** is carried by `subscribe_form=1`. Only `/subscribe` sets -it, and the action uses it for exactly two decisions: which `Signup source` to -stamp on a create, and how to treat chapter sharing (below). +`{ success: true, recordId, submission }` in stub mode. Both components store +that `recordId` and repost it as a hidden `record_id` input, which is what +makes a later submission an update instead of a duplicate. ### Component overview @@ -171,20 +145,16 @@ stamp on a create, and how to treat chapter sharing (below). It delegates rendering to a few child components and snippets: -| Child | Used for | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `Stepper.svelte` | The numbered step indicator above the form (contact mode only). | -| `ActionCards.svelte` | The "ways to help" card grid shown on the act-now confirmation / browse. | -| `Combobox.svelte` | The searchable country dropdown (used in step 1 and browse signup). | -| `Turnstile.svelte` | The Cloudflare Turnstile anti-bot widget rendered before every submission. | -| `LinkWithoutIcon.svelte`, `Socials.svelte` | Footer links on confirmation screens. | -| Snippets: `honeypotField`, `countrySelect`, `hiddenBasics`, `selectCards`, `checkboxConfirmations`, `gdprConsentField`, `nextStepBlock`, `confirmationFooter` | Reusable markup fragments shared across steps. | - -`SubscribeFlow.svelte` is deliberately much smaller: one screen of fields, the -same honeypot and Turnstile protection, and the phase machine described under -Route 3. It shares `Turnstile.svelte`, `Combobox.svelte` and the options in -`options.ts` with `OnboardingFlow`, but not the step machine or the intent -cards. +| Child | Used for | +| ------------------------------------------ | -------------------------------------------------------------------------- | +| `Stepper.svelte` | The numbered step indicator above the form (contact mode only). | +| `ActionCards.svelte` | The "ways to help" card grid shown on the act-now confirmation / browse. | +| `Combobox.svelte` | The searchable country dropdown (used in step 1 and browse signup). | +| `Turnstile.svelte` | The Cloudflare Turnstile anti-bot widget rendered before every submission. | +| `LinkWithoutIcon.svelte`, `Socials.svelte` | Footer links on confirmation screens. | + +`SubscribeFlow.svelte` is one screen of fields with the same honeypot and +Turnstile protection, plus the phase machine described under Route 3. On mount, both components fetch `GET /api/onboarding-mode`. `OnboardingFlow` logs the answer to the browser console; both use it to decide whether the @@ -273,17 +243,51 @@ question, "what do you want to do", rather than a signup: - The keep-informed and Substack opt-in cards are hidden, as is the GDPR consent checkbox, because both were answered on the subscribe form. -- Picking an intent becomes **required** to submit; on a fresh visit an opt-in - alone is enough. -- The stepper drops its first label and the Back button is gone, since there is - no step 1 to return to. -- The submit button reads "Continue" rather than "Submit", because the record - already exists and this post updates it. +- Picking an intent becomes **required** to submit. On a fresh visit an opt-in + alone is enough, so this is a different submit gate, not just a different + layout. - The two hidden inputs described under "Chapter sharing" are added. So the `Step2 --> Step1: Back` edge and the opt-in-only submit path in the diagram do not exist in this mode. +## Create versus update + +A post carrying `record_id` is an **update**; anything else is a **create**. +This is the axis most of the action's behaviour turns on, and its rules +otherwise scatter across the write and the validation, so they are collected +here. + +On an update: + +- The required-field presence check is skipped, so it cannot re-demand what the + create already collected. The `email` regex and the `intent` enum still run, + so an update carrying neither is rejected anyway. +- `Signup source` is never rewritten. It is provenance, stamped once at create: + `June 2026 subscribe form` for `/subscribe`, `June 2026 onboarding flow` + otherwise. Without that rule the volunteer step, which carries no subscribe + marker, would rewrite a subscribe row as a join row. +- GDPR consent is not required, because it was captured at create. +- `Full name`, `Country` and `City` are overwritten only when the post supplies + a non-empty value, so a partial post cannot blank what the create collected. + +That last guard covers those three fields and no others. `Email`, `Intent` and +`Email subscription` are taken from the post on every call, and +`Data privacy policy agreed` is hard-coded to `true` on every call whether or +not the post carries `agree_gdpr`. + +**The hazard that follows.** An update omitting `keep_informed` writes +`Email subscription: false`, and nothing on the server preserves it. What +preserves it is the client reposting it from state, through **two** separate +`{#if keepInformed}` hidden inputs in `OnboardingFlow`, one on the step-2 +intent form and one on the step-3 volunteer form. Dropping either silently +clears that person's subscription flag, with no error and no other symptom. +Both inputs carry a comment saying so. + +Which form posted is carried by `subscribe_form=1`, set only by `/subscribe`. +The action reads it for exactly two decisions: which `Signup source` to stamp +on a create, and how to treat chapter sharing. + ## Data written to Airtable Target: base `appWPTGqZmUcs3NWu`, table `tblL1icZBhTV1gQ9o` ("Members"). @@ -291,18 +295,13 @@ Target: base `appWPTGqZmUcs3NWu`, table `tblL1icZBhTV1gQ9o` ("Members"). **Step 2 / browse signup / subscribe form (create):** `Full name`, `Email`, `Country`, `City`, `Intent`, `Signup source`, `Email subscription` (keep_informed), `Data privacy policy agreed`, `GDPR chapter share permission`. +Every field there has a rule under "Create versus update" above or "Chapter +sharing" below, so treat this list as the index to those rules. -`Signup source` is provenance, so it is written **once at create and never on an -update**: `June 2026 subscribe form` for `/subscribe`, `June 2026 onboarding -flow` otherwise. Without that rule the volunteer step, which carries no -subscribe marker, would rewrite a subscribe row as a join row. - -**Step 3 volunteer (update, only when `volunteer_details=on`):** adds -`Discord Username`, `Phone`, `Languages`, `Other languages`, -`Discovery method of PAI`, `Discovery method of PAI (Other)`, `Motivation`, -`Motivation (Other)`, `Skills & Interests`, `Skill & Interests (Other)`, -`Projected weekly hours`, `Volunteer Agreement`, `Code of Conduct agreed`, -`Paying Interest`, and `Zip code` (US only). +**Step 3 volunteer (update, only when `volunteer_details=on`):** the volunteer +detail fields, written by the `intent === 'Volunteer' && hasVolunteerDetails` +block in the action. That block is the list. Copying it here only creates a +second one to keep in sync, which is how it came to be missing a field. ### Chapter sharing @@ -332,71 +331,50 @@ or to the global onboarding address, and whether a US signup is copied into the sheet shared with PauseAI US. Leaving it unticked is therefore a real routing decision, not a preference flag. -Those automations live in Airtable, not in this repository, so nothing here -proves that behaviour. They are documented in the `pauseai-civicrm` repository -under `notes/airtable-onboarder-automation-plan.md`, along with the field ids -each condition reads. +Those automations live in Airtable, not in this repository, so nothing here can +prove that behaviour. Read them in the base's automation editor if you need the +exact conditions. ## Validation rules -Enforced in the `submit` action before any write: - -### Bot protection (performed before all other validation) - -- **Honeypot:** a non-empty `nickname` field (a client-side hidden field) silently - returns success with no write — catches bots that render the page. -- **Turnstile verification:** server-side CAPTCHA check via Cloudflare. The token - is validated against `TURNSTILE_SECRET_KEY`, a hostname Turnstile reports is - checked against the request hostname, and test/invalid tokens are rejected. Bots that POST - directly to the endpoint (bypassing the honeypot) are blocked here. +Enforced in the `submit` action before any write. Bot protection runs before +all field validation; see "Bot protection" below. Which of these are relaxed on +an update is in "Create versus update" above. -### Field validation - -- Required **on a create**: `full_name`, `email`, `country`, `city`. An update - (the volunteer step, or the `/subscribe` hand-off) skips the presence check, - so it cannot re-demand what the create already collected. -- The email regex and the `intent` enum below still run on **every** post, - updates included, so an update carrying neither is rejected even though the - presence check was skipped. Only the country check was relaxed, to run when a - country is supplied. +- Required: `full_name`, `email`, `country`, `city`. - `email` must match `^\S+@\S+\.\S+$`. -- `country` must be in `COUNTRIES`. +- `country` must be in `COUNTRIES`, checked only when one is supplied. - `intent` must be one of `INTENTS` (`Act now` | `Volunteer` | `Lead` | `Keep informed`). -- GDPR consent (`agree_gdpr`) required **only on the create path**. Updates are - exempt because consent was captured when the record was created. `/subscribe` - posts it as a hidden field, since signing up on that form is itself the - privacy-policy consent, which its microcopy links. +- GDPR consent (`agree_gdpr`). `/subscribe` posts it as a hidden field, since + signing up on that form is itself the privacy-policy consent, which its + microcopy links. - Volunteer path additionally requires: ≥1 language, a valid `hours` value, and both `agree_volunteer` and `agree_conduct` checkboxes. -## Bot protection details - -The form implements a two-layer bot defense: - -1. **Client-side honeypot:** A hidden `nickname` input field that only bots render - and complete. When non-empty, the submission silently succeeds without writing - to Airtable or Substack, so bots learn nothing. - -2. **Server-side Turnstile verification:** Cloudflare CAPTCHA tokens are verified - on the server (function `checkNotSpam()` in `src/lib/server/turnstile-verify.ts`). - The verification checks: - - The `TURNSTILE_SECRET_KEY` is configured and is not a test key - - The token is valid and successfully verified by Cloudflare - - The hostname Turnstile reports for the token matches the request hostname, - which blocks a token minted on another origin (a deploy preview, say) and - replayed here. Note this is deliberately not a hard requirement: when - Turnstile reports no hostname the token is still accepted, so that a - missing field cannot lock out legitimate senders. It compares the hostname - only, not the full origin. - - In development, verification is skipped if the secret is missing; in production, - a missing or test secret causes the form to fail closed - - Tokens are single-use and expire after 5 minutes. After each submission - (success or failure), the frontend remounts the Turnstile widget via the - `turnstileNonce` state variable so a new token can be obtained for a retry. - -This combination blocks both bots that render the page (honeypot) and bots that -POST directly to the endpoint (Turnstile verification). +## Bot protection + +Two layers, catching different bots: + +1. **Client-side honeypot:** a hidden `nickname` input that only bots render and + complete. When non-empty the submission silently succeeds without writing + anything, so bots learn nothing. This catches bots that render the page. +2. **Server-side Turnstile verification:** `checkNotSpam()` in + `src/lib/server/turnstile-verify.ts`, which is commented in full and is the + place to read the exact checks. This catches bots that POST directly to the + endpoint, bypassing the honeypot. + +One Turnstile property is easy to over-assume, so it is worth stating here: the +token's hostname is compared against the request hostname **only when Turnstile +reports one**. An absent hostname is accepted by design, so that a missing field +cannot lock out legitimate senders, and the comparison is hostname against +hostname rather than a full origin match. + +Tokens are single-use and expire after five minutes, so the client remounts the +widget through the `turnstileNonce` state variable after every submission, +success or failure. That is a cross-file contract: drop the remount and a retry +posts a spent token. + +Turnstile runs in live mode only, for the reasons under "Live vs. stub mode". ## Live vs. stub mode diff --git a/src/lib/components/onboarding/OnboardingFlow.svelte b/src/lib/components/onboarding/OnboardingFlow.svelte index d1b9a04b0..2f29dc5e2 100644 --- a/src/lib/components/onboarding/OnboardingFlow.svelte +++ b/src/lib/components/onboarding/OnboardingFlow.svelte @@ -557,6 +557,9 @@ name="intent" value={intent ? INTENT_VALUES[intent] : 'Keep informed'} /> + {#if keepInformed} {/if} @@ -799,6 +802,9 @@ {#if recordId} {/if} + {#if keepInformed} {/if} diff --git a/src/routes/embed/onboarding-form/+page.server.ts b/src/routes/embed/onboarding-form/+page.server.ts index 04870055f..5aa804f8f 100644 --- a/src/routes/embed/onboarding-form/+page.server.ts +++ b/src/routes/embed/onboarding-form/+page.server.ts @@ -151,6 +151,8 @@ export const actions: Actions = { const fields: FieldSet = { Email: email, Intent: intent, + // Taken from the post on every call, updates included, so an update that + // omits keep_informed clears it. The forms repost it from state. 'Email subscription': keepInformed, // Signing up is itself the privacy-policy consent: the /join checkbox // and the /subscribe microcopy both link it. From df6fcc55042f1130486bee5fddd79c0a7fbed63c Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Thu, 20 Aug 2026 15:32:49 +0200 Subject: [PATCH 5/8] docs(onboarding): correct two stale comments in the submit action The chapter-routing comment said notifying the chapter stays a manual Airtable process. That has not been true for as long as the onboarder-alert automation has existed, and it is what led a reviewer to read a correct paragraph in join-form-flow.md as contradicting the code. It now says why the live branch has no use for the lookup: the automations run their own, and route on the chapter-share field this action writes. The header listed only the /join entry points, and claimed the document is the full flow contract while the body states most of the same rules inline. That instruction is what generates the duplication: told to keep a full contract in sync, the next person copies their rule into the document too. It now names both routes and says which half belongs where. --- .../embed/onboarding-form/+page.server.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/routes/embed/onboarding-form/+page.server.ts b/src/routes/embed/onboarding-form/+page.server.ts index 5aa804f8f..309c7e18c 100644 --- a/src/routes/embed/onboarding-form/+page.server.ts +++ b/src/routes/embed/onboarding-form/+page.server.ts @@ -1,7 +1,11 @@ -// Onboarding submit action — shared by step 2, browse signup, and step 3 -// volunteer update. See docs/join-form-flow.md for the full flow contract -// (fields, validation, live/stub branches). Keep that document in sync when -// changing the action's inputs or behavior. +// Onboarding submit action, shared by /join (step 2, browse signup, step 3 +// volunteer update) and /subscribe (signup, then its "do more" hand-off). +// +// docs/join-form-flow.md maps the routes and owns what no single file can show: +// the cross-file contracts between those forms and this action, and the +// cross-system ones with Airtable, Substack and Netlify. A rule that lives at +// one site belongs in a comment there, as below, rather than copied into the +// document, which is how that document came to need correcting three times. import { fail } from '@sveltejs/kit' import type { FieldSet } from 'airtable' import type { Actions } from './$types' @@ -241,9 +245,10 @@ export const actions: Actions = { return { success: true, recordId } } - // Chapter routing is recorded only for stub inspection; notifying the - // chapter stays a manual Airtable process (plan decision 6). The live - // branch never reads it, so it's resolved here rather than on every submit. + // Recorded only for stub inspection, so it is resolved here rather than on + // every submit. The live branch has no use for it: the Airtable automations + // run their own country-to-chapter lookup, and decide who hears about the + // signup from the chapter-share field written above. const chapter = await lookupChapter(fetch, country) const submission = recordStubSubmission({ airtable: { From d8e0619dc74fc920fb7169ba640c2019e7a0d2a2 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Thu, 20 Aug 2026 20:59:31 +0200 Subject: [PATCH 6/8] docs(onboarding): allow deliberate duplication between doc and comments The previous wording said a single-site rule belongs in a comment rather than copied into the document. The document then does exactly that for three rules the action already comments at their site, so the header contradicted the diff it shipped in, and the ban was too strong anyway: a rule important enough to document is usually worth stating at the line that governs it as well. The rule is now about where relying on one place fails, not about avoiding overlap. The document alone is what does not work, because whoever breaks the rule is editing the code and does not have it open. --- src/routes/embed/onboarding-form/+page.server.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/routes/embed/onboarding-form/+page.server.ts b/src/routes/embed/onboarding-form/+page.server.ts index 309c7e18c..0d94e9f7f 100644 --- a/src/routes/embed/onboarding-form/+page.server.ts +++ b/src/routes/embed/onboarding-form/+page.server.ts @@ -1,11 +1,14 @@ // Onboarding submit action, shared by /join (step 2, browse signup, step 3 // volunteer update) and /subscribe (signup, then its "do more" hand-off). // -// docs/join-form-flow.md maps the routes and owns what no single file can show: -// the cross-file contracts between those forms and this action, and the -// cross-system ones with Airtable, Substack and Netlify. A rule that lives at -// one site belongs in a comment there, as below, rather than copied into the -// document, which is how that document came to need correcting three times. +// docs/join-form-flow.md maps the routes and collects the contracts this action +// takes part in, including the cross-file and cross-system ones no single file +// can show. Update it when this action's inputs or behaviour change. +// +// Stating a rule in both places is fine where it earns its keep: one worth +// writing down there is usually worth a comment at the site it governs too, so +// whoever edits that line sees it without opening the document. What does not +// work is the document alone, which is how it came to need correcting. import { fail } from '@sveltejs/kit' import type { FieldSet } from 'airtable' import type { Actions } from './$types' From ba875fd3a2bdaa26b6f2e5a0a30611e5ef91415c Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Thu, 20 Aug 2026 21:11:25 +0200 Subject: [PATCH 7/8] docs(onboarding): restore a dropped contract and fix comments the merge left stale From a review of the commits nothing had looked at yet. - The /subscribe hidden-input trim dropped intent=Keep informed as noise. It is not: the route pins that value, so every row it creates starts there whatever the person picks later, and an Airtable automation keys on exactly that pair of signup source and intent. Restored. - The rewritten header named /join and /subscribe but not the embed route, which is itself an entry point and which the document lists as Route 2. A header that presents an inventory has to be complete. - Three comments in the action still described the pre-/subscribe topology: consent bundling as universal when it is a /join arrangement, and record creation and Substack subscription as step-2 events when /subscribe does both during its own signup. Those are exactly the site-local explanations the new convention says to trust, so leaving them stale undercuts it. - The Turnstile hostname check is also skipped in dev, and dev is independent of ONBOARDING_LIVE. - The widget remount is not universal: SubscribeFlow remounts on every result, OnboardingFlow skips its unexpected-error branch, so a retry after one of those reposts a spent token. --- docs/join-form-flow.md | 23 +++++++++++-------- .../embed/onboarding-form/+page.server.ts | 20 ++++++++-------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/docs/join-form-flow.md b/docs/join-form-flow.md index 8451a0651..890ec9622 100644 --- a/docs/join-form-flow.md +++ b/docs/join-form-flow.md @@ -76,10 +76,12 @@ The embed wrapper does four things the `/join` route does not: This route exists so someone who only wants the newsletter can finish in one screen instead of walking the multi-step join flow. It asks for the same four -basics (name, email, country, city) plus two opt-ins. Three of its hidden inputs +basics (name, email, country, city) plus two opt-ins. Four of its hidden inputs carry meaning: `subscribe_form=1` is the discriminator, `agree_gdpr=on` records -that signing up here is itself the consent, and `keep_informed=on` is posted -unconditionally, so every completed submission subscribes. +that signing up here is itself the consent, `keep_informed=on` is posted +unconditionally so every completed submission subscribes, and +`intent=Keep informed` is fixed, so every row this route creates starts at that +intent whatever the person later chooses. `SubscribeFlow` is a three-phase machine rather than a step counter: @@ -365,14 +367,17 @@ Two layers, catching different bots: One Turnstile property is easy to over-assume, so it is worth stating here: the token's hostname is compared against the request hostname **only when Turnstile -reports one**. An absent hostname is accepted by design, so that a missing field -cannot lock out legitimate senders, and the comparison is hostname against -hostname rather than a full origin match. +reports one, and only outside dev**. An absent hostname is accepted by design, +so that a missing field cannot lock out legitimate senders, and the comparison +is hostname against hostname rather than a full origin match. Note that dev and +`ONBOARDING_LIVE` are independent, so a live-mode dev build skips the check. Tokens are single-use and expire after five minutes, so the client remounts the -widget through the `turnstileNonce` state variable after every submission, -success or failure. That is a cross-file contract: drop the remount and a retry -posts a spent token. +widget through the `turnstileNonce` state variable once a submission resolves. +That is a cross-file contract: drop the remount and a retry posts a spent token. +`SubscribeFlow` remounts on every result; `OnboardingFlow` remounts on success +and on a validation failure, but not on the unexpected-error branch, so a retry +after one of those reposts a spent token. Turnstile runs in live mode only, for the reasons under "Live vs. stub mode". diff --git a/src/routes/embed/onboarding-form/+page.server.ts b/src/routes/embed/onboarding-form/+page.server.ts index 0d94e9f7f..32a8293a3 100644 --- a/src/routes/embed/onboarding-form/+page.server.ts +++ b/src/routes/embed/onboarding-form/+page.server.ts @@ -1,5 +1,6 @@ // Onboarding submit action, shared by /join (step 2, browse signup, step 3 -// volunteer update) and /subscribe (signup, then its "do more" hand-off). +// volunteer update), /embed/onboarding-form (the iframeable wrapper around the +// same component) and /subscribe (signup, then its "do more" hand-off). // // docs/join-form-flow.md maps the routes and collects the contracts this action // takes part in, including the cross-file and cross-system ones no single file @@ -101,14 +102,13 @@ export const actions: Actions = { const mode = getString(data, 'mode') === 'browse' ? 'browse' : 'contact' const newsletter = data.get('newsletter') === 'on' const keepInformed = data.get('keep_informed') === 'on' - // GDPR consent (bundled with local-chapter sharing) gates every - // record-creating submission. Step 3 volunteer posts update an existing - // record and carry no checkbox, so the check below exempts updates. + // GDPR consent gates every record-creating submission. /join bundles it with + // local-chapter sharing in one checkbox; /subscribe asks the two separately. + // Updates carry no checkbox, so the check below exempts them. const gdprAgreed = data.get('agree_gdpr') === 'on' - // Set when a step-2 submission already created the person's record: - // the later volunteer-form submission updates it instead of creating a - // duplicate. Step 2 sends every path through here, so the newsletter - // and Substack subscription happen right after step 2. + // Set when an earlier submission already created the person's record, so + // this one updates it instead of creating a duplicate: /join step 2 then the + // volunteer form, or /subscribe then its "do more" hand-off. const existingRecordId = getString(data, 'record_id') // The volunteer detail fields are only present on the step-3 form post. const hasVolunteerDetails = data.get('volunteer_details') === 'on' @@ -239,8 +239,8 @@ export const actions: Actions = { if (!recordId) { return fail(502, { message: 'Sorry, we could not save your details. Please try again.' }) } - // Subscription happens on the initial (step 2) submission only; - // the volunteer-form update never re-subscribes. + // Subscription happens on the create only, which for /subscribe is its own + // signup rather than a step 2. No update re-subscribes. if (newsletter) { await subscribeToSubstackNewsletter(email) } From b006669dcc8bb77aecda7c0e9573680ae0bda3aa Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Thu, 20 Aug 2026 21:16:12 +0200 Subject: [PATCH 8/8] fix(onboarding): remount Turnstile after an unexpected submit error The submit handler reset the widget on success and on a validation failure but not on the unexpected-error branch. Verification spends the token whether or not the write then succeeded, so after a 500 the next attempt reposted a spent token and failed the spam check until the user reloaded the page. Hoisted the reset to run on every result, which is what SubscribeFlow already does. Found while checking a claim the flow document makes about this contract, which was true of one form and not the other. --- docs/join-form-flow.md | 11 +++++------ src/lib/components/onboarding/OnboardingFlow.svelte | 10 +++++----- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/docs/join-form-flow.md b/docs/join-form-flow.md index 890ec9622..e242bb1c9 100644 --- a/docs/join-form-flow.md +++ b/docs/join-form-flow.md @@ -372,12 +372,11 @@ so that a missing field cannot lock out legitimate senders, and the comparison is hostname against hostname rather than a full origin match. Note that dev and `ONBOARDING_LIVE` are independent, so a live-mode dev build skips the check. -Tokens are single-use and expire after five minutes, so the client remounts the -widget through the `turnstileNonce` state variable once a submission resolves. -That is a cross-file contract: drop the remount and a retry posts a spent token. -`SubscribeFlow` remounts on every result; `OnboardingFlow` remounts on success -and on a validation failure, but not on the unexpected-error branch, so a retry -after one of those reposts a spent token. +Tokens are single-use and expire after five minutes, so both forms remount the +widget through a `turnstileNonce` state variable once a submission resolves, on +every result rather than only the successful ones: verification spends the token +whether or not the write succeeded. That is a cross-file contract, and dropping +the remount on any branch leaves a retry reposting a spent token. Turnstile runs in live mode only, for the reasons under "Live vs. stub mode". diff --git a/src/lib/components/onboarding/OnboardingFlow.svelte b/src/lib/components/onboarding/OnboardingFlow.svelte index 2f29dc5e2..827a2dd4a 100644 --- a/src/lib/components/onboarding/OnboardingFlow.svelte +++ b/src/lib/components/onboarding/OnboardingFlow.svelte @@ -333,19 +333,19 @@ const startValue = onStart() return ({ result }) => { submitting = false + // Every result, including the unexpected branch: verification spends the + // token whether or not the write succeeded, so a retry needs a fresh + // widget or it reposts a spent one. + turnstileToken = '' + turnstileNonce += 1 if (result.type === 'success') { // Remember the created record so later submissions in the // same flow update it rather than create a duplicate. if (typeof result.data?.recordId === 'string') { recordId = result.data.recordId } - turnstileToken = '' - turnstileNonce += 1 onSuccess(result.data, startValue) } else if (result.type === 'failure') { - // Reset Turnstile widget on failure so user can retry - turnstileToken = '' - turnstileNonce += 1 toast.error(String(result.data?.message ?? msgs.onboarding_error_generic)) } else { toast.error(msgs.onboarding_error_unexpected)