Prima Get an API key

Module kit / Forms and leads

Public Submission and Widget

forms-leads/public-submission-and-widget

Kind backend
Domain forms-leads
Maturity prod
Reusable extract
Stack fastapi-0.118, sqlalchemy-2.0-sync, postgres, pydantic-2.12
Depends on _shared/fastapi-project-layout, _shared/error-envelope, _shared/test-layout
Frontends forms-leads/ui/form-builder-page
Reference implementation
  • alchemy_labs/backend/api/routers/public_forms_router.py
  • alchemy_labs/backend/static/form-widget.js
  • alchemy_labs/backend/services/forms/answer_piping.py
  • alchemy_labs/backend/schemas/forms.py
  • alchemy_labs/backend/models/forms.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

A visitor on any external site (via a <div data-alchemy-form> snippet plus embed.js, an iframe pointed at the hosted /page, or a bare raw POST from a custom client) can look up, render and submit an Alchemy Labs form with zero authentication, and the submission becomes at most one saved FormSubmission row plus a resolved thank-you/redirect response, even when the visitor is a bot, closes the tab mid-request, or the AI-generation call downstream fails. The invariant that makes it trustworthy: submit_public_form/submit_public_form_with_ai treat every side effect that runs after the submission row is committed (lead capture, Quentn sync, Telegram/email notification, lead-magnet delivery, commerce checkout, Prima AI generation) as fire-and-forget behind its own try/except, so nothing a downstream integration does can turn an already-saved submission into an error response the visitor sees (public_forms_router.py 704-750, 804-805, 834-835, 862-863, 948-949, 1183-1288).

When to use

Read it before touching _find_embed, get_public_form(_template), submit_public_form(_with_ai), _is_bot_submission/_validate_enhanced_protection, resolve_thank_you_page, get_embed_script, get_form_page, or anything in form-widget.js's render/submit/session path. Trigger phrases: "embed code", "embed.js", "form-widget", "aw-input"/"aw-pill", "public form 404", "domain not allowed", "honeypot", "PoW challenge", "bot_submission_blocked", "FORM_CONFIG", "thank you page not resolving", "submit-ai", "ai_prompt".

Not this recipe: the authoring/CRUD side of a form template (/api/forms/..., field editing, the AI form-editor, the redirect-rules endpoints) is forms-leads/ui/form-builder-page's backend, a separate recipe; services/forms/session_service.py's FormSessionService itself: this recipe's session endpoints (session/start, .../save, .../email, GET .../session/{token}) call it but do not own its internals; services/forms/lead_integration.process_submission_lead (a separate lead/CRM-capture recipe, this recipe only calls it and reads back .lead/.event_id/.meta_event); the Quentn newsletter sync (_sync_to_quentn) and the appointment-invite dispatcher (_send_form_appointment_invite), both defined in this file but each is really a thin adapter into a messaging/CRM recipe, cited for context, not the subject here; the Commerce checkout creation inside submit_public_form (Payrexx gateway/order creation, 865-956) belongs to commerce-payments, and this recipe only tracks where its redirect_url/checkout_url re-enters the response; GET /api/protection/challenge and POST /api/protection/validate in the sibling protection_router.py: the hosted /page embeds its own challenge inline via form_protection.generate_challenge() at submit-page render time (1761), and the widget never calls those two endpoints itself, so they are out of scope even though _validate_enhanced_protection calls into the same FormProtectionService class; FormProtectionService (services/form_protection_service.py) itself, used here but not measured in depth; the authoring-time HTML sanitization of rich_content (nh3, in services/forms/template_service.py): this recipe's widget only re-sanitizes client-side as defense-in-depth over an already-sanitized string.

Stack assumptions

At 62ba4fc: fastapi==0.118.2, synchronous handlers on sqlalchemy.orm.Session (SQLAlchemy==2.0.43), pydantic==2.12.0, starlette==0.48.0, psycopg2-binary==2.9.10 (Postgres INET/JSONB columns on every table this recipe reads). Postgres version not independently reverified in this pass; see _shared/fastapi-project-layout. The widget (form-widget.js, 1508 lines) is plain ES5, zero dependencies, no build step, no framework, a single self-executing IIFE served as a static file, including its own from-scratch SHA-256 implementation for the proof-of-work Web Worker (708-859).

Foundations assumed: _shared/fastapi-project-layout for app boot and router mounting: public_forms_router.router is mounted with prefix="/api" and surfaces=surface_exempt("public", "form embeds on client sites, no auth by design") at app_fastapi.py:949, guarded by a PUBLIC_FORMS_ROUTER_AVAILABLE import-success flag, not a feature switch; _shared/error-envelope for the exception handling this router's raised HTTPExceptions ultimately hit (app_fastapi.py 724-793 http_exception_handler, 795-814 general_exception_handler, schemas/error_schemas.py create_error_response; see Gotchas for a third envelope shape that can pre-empt both); _shared/test-layout for the db_session/client fixtures both test files below depend on (tests/v2/coverage/conftest.py).

Explicitly not assumed: _shared/auth-dependency-pattern, _shared/workspace-scoping. Every route in this recipe is surface_exempt("public", ...) and derives no user, session or workspace at all; "tenancy" here is only which template_id an active FormEmbed.embed_code happens to point at (Security & tenancy).

Env vars: settings.FRONTEND_URL (core/config.py:43, default "http://localhost:3000") is read once in get_embed_script (1543) to compute a frontend_url fallback, but that variable is never referenced anywhere else in the function; the script it generates hardcodes API_BASE = 'https://opus.alchemy.zuerich/api/f/' on line 1564 regardless of environment (see Gotchas). No other env var is read by public_forms_router.py itself; FormProtectionService.__init__ reads settings.SECRET_KEY (out of this recipe's scope, cited under Security & tenancy).

Data model

This recipe owns no migrations of its own; it reads and writes rows on tables authored elsewhere (forms-leads/ui/form-builder-page's domain). Shapes as measured from models/forms.py, cited for the columns this recipe's functions actually touch:

form_embeds (                                              -- models/forms.py 704-854
  id                uuid PRIMARY KEY,
  template_id       uuid NOT NULL REFERENCES form_templates(id) ON DELETE CASCADE,
  embed_code        varchar(64) UNIQUE NOT NULL,            -- the no-auth lookup key
  embed_type        varchar(50) DEFAULT 'inline',           -- CHECK IN ('inline','popup','slider','fullpage')
  is_active         boolean DEFAULT true,
  allowed_domains   jsonb DEFAULT '[]',                     -- fnmatch patterns, checked ONLY on GET /{embed_code}
  style_config      jsonb DEFAULT '{}',                     -- merged over get_default_style_config() (26 keys)
  trigger_config    jsonb DEFAULT '{}',
  custom_css        text,
  width varchar(50) DEFAULT '100%', max_width varchar(50) DEFAULT '600px', height varchar(50) DEFAULT 'auto',
  view_count        integer DEFAULT 0,                      -- incremented by GET /{embed_code} only
  submission_count  integer DEFAULT 0                       -- incremented by /submit and /submit-ai
);

form_submissions (                                          -- models/forms.py 443-492
  id                uuid PRIMARY KEY,
  template_id       uuid NOT NULL REFERENCES form_templates(id) ON DELETE CASCADE,
  session_id        uuid REFERENCES form_sessions(id) ON DELETE SET NULL,
  lead_id           uuid REFERENCES leads(id) ON DELETE SET NULL,         -- set by a neighbouring recipe
  contact_id        uuid REFERENCES tracking_contacts(id) ON DELETE SET NULL,  -- backfilled later, not by this recipe
  data              jsonb NOT NULL DEFAULT '{}',             -- honeypot keys stripped before write (clean_data)
  metadata          jsonb DEFAULT '{}',                      -- {embed_code, referer, origin[, endpoint]}
  tracking_data     jsonb DEFAULT '{}',
  ip_address        inet, user_agent text,
  submission_time   timestamptz DEFAULT now()
);

form_sessions (                                              -- models/forms.py 561-701
  id uuid PRIMARY KEY, template_id uuid NOT NULL REFERENCES form_templates(id) ON DELETE CASCADE,
  session_token     varchar(64) UNIQUE NOT NULL,
  anonymous_id varchar(255), utm_source/medium/campaign/content/term varchar(255), fbclid/gclid varchar(255),
  partial_data      jsonb DEFAULT '{}', current_page integer DEFAULT 1,
  status            varchar(50) DEFAULT 'in_progress',        -- CHECK IN ('in_progress','completed','abandoned')
  email_captured    varchar(255)
);

form_analytics (                                             -- models/forms.py 522-558
  id uuid PRIMARY KEY, template_id uuid NOT NULL REFERENCES form_templates(id) ON DELETE CASCADE,
  submission_id uuid REFERENCES form_submissions(id) ON DELETE CASCADE,
  event_type        varchar(100) NOT NULL,   -- CHECK includes 'form_submitted','bot_submission_blocked', 11 others
  event_data jsonb DEFAULT '{}', ip_address inet, user_agent text
);

thank_you_pages (                                            -- models/forms.py 856-930
  id uuid PRIMARY KEY, template_id uuid NOT NULL REFERENCES form_templates(id) ON DELETE CASCADE,
  title varchar(500) DEFAULT 'Thank you!', message text, rich_content text,   -- sanitized at write time, elsewhere
  redirect_url text, redirect_delay_seconds integer DEFAULT 0,
  conditions        jsonb DEFAULT '[]',       -- [{field, operator, value}, ...], matched against clean_data
  is_default boolean DEFAULT false, priority integer DEFAULT 0
);

form_fields (138-187) and form_templates (18-53) are read, not written, by this recipe: FormField.field_type is a CHECK over 16 values including slider and password; FormField.answer_piping (jsonb, e.g. {"format": "Hi {{name}}!"}) exists on the row but see Gotchas, nothing in this recipe's call graph ever reads it. FormTemplate.settings is an untyped jsonb bag this recipe reads several keys out of by hand: secure_email, quentn, appointment/messages, lead_magnet, ai_prompt, design/style_overrides, redirect_url/success_message/success_title/redirect_delay_seconds. What is NOT stored anywhere in this recipe's write path: the honeypot field values themselves (clean_data strips every key in HONEYPOT_FIELDS before either FormSubmission.data or the secure-email send, 675, 1127); a PSP response body (the Commerce branch stores only payrexx_gateway_id/checkout_url, 938-940).

API surface

All mounted at prefix /api (app_fastapi.py:949), so every path below is /api/f/{embed_code}/.... No auth dependency on any of them.

RouteCodesNotes
OPTIONS /{embed_code}[/submit|/submit-ai|/session|/session/{token}] (225-242)200Static CORS-preflight handler, echoes Origin back, Access-Control-Allow-Credentials: true
GET /{embed_code} → get_public_form (279-339)200; 404 "Form not found" (embed missing/inactive or lookup miss); 404 "Form not found or not public" (template missing or is_public=false); 403 "Domain not allowed"The only route that checks allowed_domains (298, against the Origin header); see Security & tenancy
GET /{embed_code}/template → get_public_form_template (342-383)200; 404 "Form not found" (embed OR non-public template, same message either way, unlike the route above)Full template.to_dict() + merged style + embed config
POST /{embed_code}/submit → submit_public_form (591-993), response_model=PublicSubmissionResponse200 always on a reachable embed (bot detection returns a fake 200 success, not 4xx); 404 "Form not found"; 422 (missing data)Bot-blocked path never touches FormSubmission; real path writes it, increments submission_count, fires all side effects, resolves thank-you/redirect
POST /{embed_code}/submit-ai → submit_public_form_with_ai (1037-1360), response_model=PublicAISubmissionResponseSame codes as /submitIdentical pipeline plus a non-streaming Prima call (mode="trust", max_tokens=1200); ai_skipped/ai_error never fail the submission
POST /{embed_code}/session/start → start_public_session (1393-1425)200; 404 "Form not found"Delegates to FormSessionService.create_session (not owned here)
POST /{embed_code}/session/{token}/save → save_partial_public (1428-1445)200 {success, last_saved}; 404 "Session not found"
POST /{embed_code}/session/{token}/email → capture_email_public (1448-1464)200; 404; 422 (missing email query param)
GET /{embed_code}/session/{token} → get_public_session (1467-1511)200 (includes completion_percentage); 404 "Session not found"
GET /{embed_code}/embed.js → get_embed_script (1518-1673)200 always (an unknown/inactive embed returns 200 with a console.error(...) body, never 404)media_type="application/javascript", Cache-Control: public, max-age=60
GET /{embed_code}/page → get_form_page (1680-1815)200; 404 HTML body (embed inactive/missing, or template missing)X-Frame-Options: ALLOWALL; embeds FORM_CONFIG + <script src="/static/form-widget.js?v=<cache_bust>">

PublicSubmissionRequest (schemas/forms.py 653-667): data: Dict[str, Any] (required), session_token: Optional[str], tracking: Optional[TrackingData], protection fields accepted either flat (_protection_token etc.) or nested under protection/_protection (ProtectionData, populated by alias). PublicSubmissionResponse (670-683) carries success, submission_id, lead_id, thank_you_page, redirect_url, message, conversion, no redirect_delay_seconds field at the top level (see Gotchas). PublicAISubmissionResponse (686-698) adds generated_content, ai_skipped, ai_error.

Application logic

submit_public_form, in commit order (591-993; submit_public_form_with_ai at 1037-1360 mirrors steps 1-9 exactly, then adds AI generation before resolving the thank-you page):

  1. Resolve embed via _find_embed (245-272): exact embed_code match first (is_active=true); on a miss, if the code parses as a UUID, a fallback query by template_id; a non-UUID miss short-circuits (no second query, test_find_embed_non_uuid_code_skips_fallback). No embed → 404.
  2. Resolve template by embed.template_id; missing or is_public=false → 404. No domain check here (unlike GET /{embed_code}).
  3. Load the FormSession by submission.session_token if present, else None.
  4. Bot protection, both layers run unconditionally and independently: _validate_enhanced_protection (140-182, PoW/token/honeypot via FormProtectionService, backwards-compatible allow when no protection data is sent at all) AND the legacy _is_bot_submission (120-137: honeypot-in-data → timing → per-IP-hash rate limit, first true wins). Either layer failing sets is_bot/is_valid=False; on failure: a FormAnalytics(event_type='bot_submission_blocked') row is written and committed, and the handler returns a fake PublicSubmissionResponse(success=True, submission_id=<all-zero UUID>, ...), never a 4xx, so a bot cannot learn it was caught (654-661).
  5. Build tracking_data from submission.tracking merged with session.get_attribution_data(), plus server-captured client_ip/client_user_agent for Meta CAPI (663-672).
  6. clean_data = data minus every key in HONEYPOT_FIELDS (675).
  7. Secure medical-form branch: if template.settings.secure_email.enabled, email clean_data via the workspace's SMTP relay (_send_secure_submission, 386-425) and return immediately, nothing is persisted: no FormSubmission, no lead, no analytics, no PHI in logs (677-688). Raises HTTPException(500|502) on misconfiguration or send failure rather than reporting a false success.
  8. Otherwise: insert FormSubmission, mark the session completed if present, increment embed.submission_count, insert a form_submitted FormAnalytics row, single db.commit() + db.refresh() (690-728).
  9. Best-effort side effects, each independently caught so none can fail the submission: process_submission_lead (734-742, own rollback on failure); _sync_to_quentn (428-519, opt-in via settings.quentn.enabled, never raises); _send_form_appointment_invite (522-588, opt-in via settings.messages/settings.appointment, never raises); creator email notification (752-805); Telegram notification (807-835); lead-magnet email, fired via asyncio.create_task so it does not block the response at all (837-863).
  10. Commerce: if template.attached_product_id resolves to an active Product, create a pending Order and a Payrexx gateway (subscription/intro products tokenize; one-time products get a direct-sale gateway), catching and rolling back on any failure so the submission still succeeds without a checkout (865-956).
  11. Resolve the thank-you response: resolve_thank_you_page (1363-1386, priority-desc pages, first conditional match wins over is_default, else first page) or a synthesized dict from template.settings (958-975). checkout_redirect from step 10, if any, overrides the thank-you redirect and suppresses thank_you_page in the response entirely (977-985).

resolve_thank_you_page(template_id, form_data, db) (1363-1386): fetch all pages for the template ordered by priority DESC; first page whose conditions matches form_data wins regardless of priority ordering among the rest; else the page with is_default=true; else the first page in the (already priority-ordered) list; else None. Matching happens before the default fallback, so a low-priority conditional match still beats a high-priority default (test_resolve_thank_you_conditional_beats_higher_priority_default).

submit_public_form_with_ai deltas: skips the Commerce branch entirely (not present in this handler); after the same steps 1-9, reads template.settings.ai_prompt; if absent/blank, ai_skipped=True and nothing is called; else _substitute_ai_prompt (1011-1034, regex-only {{field}} substitution, no Jinja runtime, so {% %} blocks and non-identifier expressions pass through literally, never evaluated) fills the prompt from clean_data, then get_prima_router().route_request(PrimaRequest(mode="trust", stream=False, max_tokens=1200, ...), system_prompt=DEFAULT_AI_SUBMISSION_SYSTEM_PROMPT); any exception is caught into ai_error (a string containing the exception class name) without failing the submission (1293-1324).

form-widget.js render/submit path (init 1445-1499 → renderForm 1176-1236 → formWidget.submit 1436): field onchange/onblur handlers write straight into the in-memory formData object, never re-read from the DOM at submit time, except the last-focused free-text field, which fires no onchange until blur; flushDomValues (934-942) is called at the top of both submitForm and next() specifically to harvest every .aw-input's live value before validating/sending, so an in-progress keystroke is never silently dropped (927-933, comment documents the exact touch-device failure mode this fixes). submitForm → waits up to ~700ms in 50ms increments for the parent-page tracking bridge to resolve (parentTrackingResolved, bounded so a slow/absent parent never blocks a submit indefinitely, 944-967) → _continueSubmit builds {data: visibleData, session_token, tracking, _protection_token, _protection_pow_challenge, _protection_pow_solution, _protection_honeypot} and POSTs to apiBase + '/submit' (969-1006). On success: clears the saved session from localStorage; postMessages alchemy-labs-form-submitted to the parent (for the parent's own pixel/CAPI dedup, keyed by the server's conversion.event_id); honors a top-level redirect_url before falling back to thank_you_page (1052-1060); else renders the thank-you HTML in place (renderThankYou, 1103-1170).

Invariants

  • A bot-detected submission never reaches the database as a FormSubmission and never differs, response-wise, from a real success (test_submit_honeypot_returns_fake_success_and_logs_bot, test_submit_ai_honeypot_returns_fake_success).
  • Honeypot field values are stripped from data before it is persisted anywhere, including the secure-email path (clean_data = {k:v for k,v in data.items() if k not in HONEYPOT_FIELDS}, 675/1127).
  • A failure in lead integration, Quentn sync, messaging, notification email, Telegram, lead-magnet delivery, or commerce checkout creation never turns a saved submission into an error response (test_submit_lead_integration_failure_does_not_break_submission; every other side effect is wrapped the same way but not independently covered by the given test files).
  • A secure-medical-form submission (settings.secure_email.enabled) persists nothing at all, no FormSubmission, no FormAnalytics, no lead, success or failure both bypass the database entirely (386-425, 677-688).
  • The AI-generation call never fails the submission: ai_error is populated and submission_id is still the real row's id (test_submit_ai_error_path_when_prima_raises).
  • resolve_thank_you_page always checks every page's conditions before falling back to is_default, regardless of stored priority order (test_resolve_thank_you_conditional_beats_higher_priority_default).
  • _find_embed's UUID fallback query only ever fires when the raw embed_code string parses as a UUID; a non-UUID miss issues exactly one query (test_find_embed_non_uuid_code_skips_fallback).

Frontend

forms-leads/ui/form-builder-page   the admin authoring surface for FormTemplate/FormField/FormEmbed/
                                    ThankYouPage: this recipe's tables are all written there and only
                                    read here; nothing in this recipe renders an authoring UI of its own

No dedicated admin/debug route exists in this file for the public flow. The closest operator-visible surface is data, not a page: FormEmbed.view_count/submission_count (incremented in place, no history) and FormAnalytics rows with event_type IN ('form_submitted', 'bot_submission_blocked') carrying {embed_code, ip_hash | field_count} in event_data.

Security & tenancy

Auth dependency: none. Every route is mounted surface_exempt("public", "form embeds on client sites, no auth by design") (app_fastapi.py:949). Workspace/tenant derivation: none directly; the only scoping boundary is which template_id an active FormEmbed.embed_code resolves to, and whether that template's is_public flag is set. There is no user or workspace context on any request in this recipe at all.

allowed_domains is enforced on exactly one route. is_domain_allowed (models/forms.py:844) has a single call site in the whole file: inside get_public_form (298), checked against the Origin header. get_public_form_template, get_embed_script, get_form_page, submit_public_form, and submit_public_form_with_ai never call it. A domain-restricted embed's page still renders and its submit endpoint still accepts a POST from any origin; the only thing gated by domain is the JSON metadata endpoint most integrations never call directly. Submission is protected only by the bot-detection layers in Application logic step 4, not by origin.

Fail direction, by layer: honeypot/timing/rate-limit and PoW/token all fail toward "treat as bot" (fake success, no persistence) rather than a visible error, so a false positive is silent to the visitor and undetectable to an attacker; _validate_enhanced_protection explicitly falls back to allow when no protection payload is sent at all (163-165, "backwards compatibility"): an integration that never sends _protection_token/_protection_pow_challenge skips PoW and the token-based timing/rate-limit checks entirely and relies solely on the legacy honeypot+timing+per-IP rate limit; a partial protection payload (token present, no PoW solution) is rejected outright as suspicious rather than falling back (167-170). CORS: cors_preflight (230-242) and get_embed_script/get_form_page all echo */the request Origin with Access-Control-Allow-Credentials: true, by design, since this recipe is meant to be embedded from any client site.

Rate limiting: the legacy path is a module-global, per-process, in-memory dict keyed by sha256(ip)[:16] (_rate_limit_store, 62; RATE_LIMIT_MAX_SUBMISSIONS=10 per RATE_LIMIT_WINDOW_SECONDS=60); it resets on process restart and is not shared across workers; the enhanced path (FormProtectionService._check_rate_limit) uses Redis when reachable and the same kind of in-memory fallback otherwise (FORM_SUBMISSIONS_PER_HOUR=5), a separate, differently-scaled limit than the legacy one, both of which can be live on the same request.

Secrets/PII: IPs are hashed (_get_ip_hash, sha256[:16]) before being logged or written into FormAnalytics.event_data, but the raw ip_address is still stored in full on FormSubmission/FormSession/FormAnalytics (INET columns); hashing is applied only to the bot-detection log lines, not to the persisted row. SECRET_KEY (via FormProtectionService) signs the protection token; not read directly by this router. Attacker-controlled inputs: embed_code (path, tried as a literal string then as a UUID, no format validation beyond that); data: Dict[str, Any] (fully open-shaped, whatever keys a client sends land in clean_data/FormSubmission.data verbatim, honeypot keys excepted); session_token (looked up by exact string match, a guess yields a 404, no rate limit on guesses); every protection field (client-supplied, only meaningful once run through FormProtectionService).

Tests

Run 2026-09-12 against the pinned commit's own venv (alchemy_labs/backend/.venv):

python -m pytest tests/v2/coverage/test_forms_g3_answerpipingengine_placeholder_substitution_modifiers_date_html_escape_.py -q --no-cov -o addopts="" -p no:cacheprovider

prints 86 passed, pure, no DB, no app boot.

python -m pytest tests/v2/coverage/test_forms_g10_public_forms_router_bot_protection_helpers_embed_lookup_thank_you_resolution_pure_unit_.py -q --no-cov -o addopts="" -p no:cacheprovider

prints 49 passed (46 def test_ + 3 extra from @pytest.mark.parametrize over HONEYPOT_FIELDS): pure helpers by direct call, plus _find_embed/resolve_thank_you_page against a hand-written fake Session (the file's own docstring explains why: the shared 152-table schema fixture does not round-trip CREATE TYPE cleanly across repeated drop_all/create_all in this environment).

test_forms_g11_..._testclient_no_auth_public_flow_.py (37 def test_, uses the real TestClient + db_session fixtures against /api/f/... and /api/protection/...) is the one file in this set that needs a live, freshly-migrated schema. Run standalone in this session against the sandbox's scratch Postgres it produced 19 passed, 7 failed, 11 errors, every failure/error a relation "form_embeds" does not exist / relation "system_logs" does not exist ProgrammingError: the create_all()-in-test-mode path did not materialize this file's tables in an isolated single-file run, exactly the round-trip fragility tests/v2/coverage/conftest.py's own module docstring names ("this suite is intended to be run against a freshly reset public schema... invoke pytest once"), not a defect in the router. I did not get a clean full-suite run of this file in this pass; treat its pass count as unverified here rather than invented, and rerun it against a freshly reset schema before relying on it.

Known-answer vectors (G3): {{name}} → HTML-escaped by default (&lt;script&gt;, &quot;), raw when escape_html=False; {{field|default:"..."}}, |upper|lower|title|capitalize|strip|truncate:"N", |date:"...", |if:"v":"then":"else", |exists:"yes":"no", |length, |join:", ", |first, |last, |number:"N", |currency:"$" all exercised; missing field and None both render as "". Injection-safety (G10): _substitute_ai_prompt("{{ 7 * 7 }} and {% for x in y %}{{x}}{% endfor %}", ...) leaves 7 * 7 and the {% %} block untouched: no Jinja runtime, confirmed by assert "49" not in out.

Bot-protection isolation (G10): _rate_limit_store is module-global and every test that touches it clears it first/after via the clean_rate_store fixture; distinct IP hashes are proven isolated from each other's limits.

What a new app adds and this set of files lacks: no dedicated Playwright/browser test of the widget's own render/submit path exists among the given source+test files, frontend/e2e/forms-gate.spec.ts (see Deploy & ops) fills that role but drives the deployed hosted /page against real staging/prod data, not a hermetic unit; no test in the given files exercises get_embed_script or get_form_page (the HTML/JS-generation endpoints) directly, so the embed.js hardcoded-origin gap and the frontend_url dead-code finding (Gotchas) were found by reading, not by a failing assertion.

Deploy & ops

Config: none of this router's own env vars gate deploy (FRONTEND_URL only feeds dead code, above); mounting is conditional only on PUBLIC_FORMS_ROUTER_AVAILABLE, an import-success flag in app_fastapi.py, not a runtime toggle. No dedicated migration belongs to this recipe (Data model). No scheduler job. Static file: form-widget.js is served from backend/static/ (mounted generically, app_fastapi.py "Static files mounted from .../backend/static") with the page's own ?v=<cache_bust> query param (an md5 of template.updated_at + embed.updated_at, 1769-1772) as the only cache-busting mechanism, Cache-Control: public, max-age=60 on both /embed.js and /page.

The deploy gate that actually exercises this recipe end-to-end is frontend/e2e/forms-gate.spec.ts (186 lines): enumerates every public FormTemplate via /api/forms/templates, then for each one drives a real browser to ${API}/api/f/{embed_code}/page, fills every field by field_type (skipping honeypots by name pattern), waits ~3.5s (the widget's own timing check would otherwise flag it as a bot), clicks the last <button> on the page (documented as always the submit control, since option-pill buttons for select/radio fields precede it in the DOM), and asserts the success copy renders. It is a write test against real staging/prod data (tagged deploy-gate for later purge) and is the only given test that touches form-widget.js as a browser would.

Gotchas

  • 2026-09-12 (measured at 62ba4fc, public_forms_router.py 1542-1564): get_embed_script computes a frontend_url fallback from settings.FRONTEND_URL (1543-1546) and then never uses it: the generated script's API_BASE is the literal string 'https://opus.alchemy.zuerich/api/f/' (1564), unconditionally. Every embed.js served from any environment, including local development, points its form's submit/session traffic at the production API host.
  • 2026-09-12 (measured at 62ba4fc, schemas/forms.py 670-698 vs form-widget.js 1052-1060): neither PublicSubmissionResponse nor PublicAISubmissionResponse has a top-level redirect_delay_seconds field, but _continueSubmit's commerce-checkout-redirect branch reads result.redirect_delay_seconds || 0; it is always 0 in practice, so a delayed top-level redirect (as opposed to the thank_you_page.redirect_delay_seconds path, which IS populated and DOES work) is unreachable dead code on both sides.
  • 2026-09-12 (measured at 62ba4fc, models/forms.py:176 vs form-widget.js 331-423): password is a legal FormField.field_type per the database CHECK constraint, but renderField's switch has no password case: it falls through to the default: branch and renders type="text", not type="password". A password-typed field is not masked by this widget.
  • 2026-09-12 (measured at 62ba4fc): two independently-tuned timing thresholds run on every /submit: the router's own legacy _check_timing uses MIN_SUBMISSION_TIME_SECONDS = 2 (public_forms_router.py:59), while FormProtectionService's token validation uses that class's own MIN_SUBMISSION_TIME_SECONDS = 3 (form_protection_service.py:39) measured from the token's own embedded timestamp, not the FormSession.started_at the legacy check uses. They can disagree on the same request depending on when each clock started.
  • 2026-09-12 (measured at 62ba4fc): services/forms/answer_piping.py's AnswerPipingEngine/process_text/process_field_config are exported from services/forms/__init__.py and FormField.answer_piping/template.settings can carry {{field}} templates, but grepping the whole backend/ tree for process_field_config/process_text( outside answer_piping.py, its own __init__.py re-export, and its tests finds zero call sites: not in public_forms_router.py, not in template_service.py, not in form-widget.js. At this commit the answer-piping engine is unwired into the public submission/rendering path: nothing calls it on a live request. Treat it as a standalone, tested-but-unused utility until proven otherwise.
  • 2026-09-12 (measured at 62ba4fc): add_cors_headers (211-218) is defined but has zero call sites in the file: dead code, superseded by the per-route header-setting already present in cors_preflight/get_embed_script/get_form_page.
  • 2026-09-12 (measured at 62ba4fc): test-form.html (203 lines, backend/static/) is not this recipe's standalone form page. It is a separate hand-built harness that posts to /api/track/test-form-submit for exercising scribe.js tracking, with no embed_code, no FormEmbed lookup, and no relation to get_form_page/form-widget.js. It was named in this recipe's source list but does not belong to the flow described here; the actual standalone page is generated inline by get_form_page (1793-1806).
  • 2026-09-12 (measured at 62ba4fc, models/forms.py:753 CHECK vs form-widget.js 1649-1656): embed_type allows 'slider' and 'fullpage' at the database level, but embed.js's generated init() only branches on 'inline'/'popup': the function's own comment reads // TODO: Add slider, fullpage types. An embed saved with either unimplemented type renders nothing client-side.

Reference implementation

Paths relative to the repo root; all at alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.

  • alchemy_labs/backend/api/routers/public_forms_router.py (1816 lines): bot-protection helpers (68-218); CORS preflight (225-242); _find_embed (245-272); get_public_form (279-339); get_public_form_template (342-383); _send_secure_submission (386-425); _sync_to_quentn (428-519); _send_form_appointment_invite (522-588); submit_public_form (591-993); _substitute_ai_prompt (1011-1034); submit_public_form_with_ai (1037-1360); resolve_thank_you_page (1363-1386); session endpoints (1393-1511); get_embed_script (1518-1673); get_form_page (1680-1815).
  • alchemy_labs/backend/static/form-widget.js (1508 lines): config/DOM helpers + sanitizeRichHtml (14-112); tracking capture + parent postMessage bridge (114-196); style application (198-243); iframe resize postMessage (245-283); field rendering, all types (327-459); conditional-logic engine (499-601, a JS port of a Python conditional_logic.py not among the given source files); client validation (603-678); bot protection, honeypot, PoW Web Worker + sync fallback, own SHA-256 (680-859); session management, fetch calls (861-921); flushDomValues/submitForm/_continueSubmit (923-1076); thank-you rendering + doRedirect (1078-1170); main render (1172-1236); public formWidget API (1238-1439); init (1441-1507).
  • alchemy_labs/backend/services/forms/answer_piping.py (445 lines): AnswerPipingEngine.process_text/_apply_modifier/_format_date/_value_to_string (60-396); module-level convenience wrappers (399-445). See Gotchas, unwired into this recipe's live request path.
  • alchemy_labs/backend/schemas/forms.py: TrackingData (298-326); StartSessionRequest/Response (329-345); SavePartialRequest/CaptureEmailRequest (347-356); FormSessionResponse (364-396); PublicFormResponse (631-639); ProtectionData (642-650); PublicSubmissionRequest (653-667); PublicSubmissionResponse (670-683); PublicAISubmissionResponse (686-698).
  • alchemy_labs/backend/models/forms.py: FormTemplate (18-137), FormField (138-442), FormSubmission (443-519), FormAnalytics (522-558), FormSession (561-701), FormEmbed incl. get_merged_style_config/is_domain_allowed (704-854), ThankYouPage (856-930).
  • alchemy_labs/backend/app_fastapi.py: router mount + surface_exempt (947-950).
  • Tests as listed under Tests: alchemy_labs/backend/tests/v2/coverage/test_forms_g10_..._pure_unit_.py (587 lines), test_forms_g11_..._no_auth_public_flow_.py (722 lines), test_forms_g3_answerpipingengine_..._html_escape_.py (464 lines); alchemy_labs/frontend/e2e/forms-gate.spec.ts (186 lines).

Deviations of the reference from an ideal implementation, all dated 2026-09-12: allowed_domains is checked on one read route and not on submission; embed.js's API_BASE ignores FRONTEND_URL entirely; redirect_delay_seconds is read by the widget from a response field the schema never sends; password fields render unmasked; the answer-piping engine is fully unwired from the live path; add_cors_headers and the slider/fullpage embed-type branches are dead code; test-form.html is an unrelated harness, not this flow's standalone page (all detailed in Gotchas).

Acceptance checks

Commands a verifier can run without the author, from alchemy_labs/backend at 62ba4fc (or an app built to this recipe), using its own .venv:

  1. python -m pytest tests/v2/coverage/test_forms_g3_answerpipingengine_placeholder_substitution_modifiers_date_html_escape_.py -q --no-cov -o addopts="" -p no:cacheprovider prints 86 passed.
  2. python -m pytest tests/v2/coverage/test_forms_g10_public_forms_router_bot_protection_helpers_embed_lookup_thank_you_resolution_pure_unit_.py -q --no-cov -o addopts="" -p no:cacheprovider prints 49 passed.
  3. python -m pytest tests/v2/coverage/test_forms_g11_public_forms_router_endpoints_protection_router_testclient_no_auth_public_flow_.py -q --no-cov -o addopts="" -p no:cacheprovider against a freshly reset scratch schema (see Tests) is expected to pass all 37; a stale/partial schema instead reproduces relation "form_embeds" does not exist 500s in place of the expected 404s. That pattern in the output means "reset the schema", not "the router is broken".
  4. grep -n "add_cors_headers(" backend/api/routers/public_forms_router.py prints exactly one line (the definition, 211): confirms it has zero call sites.
  5. grep -rn "process_field_config\|process_text(" --include="*.py" backend | grep -v "services/forms/answer_piping.py\|services/forms/__init__.py\|tests/" prints nothing: confirms the answer-piping engine is unwired.
  6. grep -n "is_domain_allowed" backend/api/routers/public_forms_router.py prints exactly one line (298, inside get_public_form): confirms allowed_domains is not enforced on any other route in this file.
  7. grep -n "redirect_delay_seconds" backend/schemas/forms.py prints nothing under PublicSubmissionResponse/PublicAISubmissionResponse (630-699): confirms the field the widget reads at top level is never declared there.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.