Public Endpoint MVP (0.3.x)

The 0.3.x milestone: the smallest set of changes that make webform-relay safe to expose as a public form endpoint on the internet, with no ongoing maintenance or fixed infrastructure cost. It is a focused slice of the broader Production Hardening backlog — four items chosen because each is small, high-value, and adds no always-on resource:

  1. CORS config (3a, infra) — an origin allowlist so fetch-based embeds work and only known sites may POST.
  2. Stage throttling (1a, infra) — a global request cap on the HTTP API so a single client cannot drive unbounded Lambda/SES/downstream cost.
  3. Per-output on_error (2a, red/green) — one dead optional output no longer 500s the whole submission.
  4. Honeypot (1b, red/green) — silently drop obvious bot submissions.

Everything else in the hardening backlog — durable async relay (SQS/S3 spool), DLQ + alarms, AWS WAF per-IP rules, the min-fill signed token, the success redirect, and the should-fix operational items — is deferred to 0.4.x and tracked in Production Hardening.

Why these four (and why no ongoing cost)

The MVP scope was chosen so the whole milestone stays inside AWS’s perpetual free tier and adds nothing that runs while idle:

  • CORS and stage throttling are configuration on the existing HTTP API — no new resource, no charge.
  • on_error and the honeypot are pure handler/config logic — no new AWS resource at all.

The durability work (SQS + worker Lambda) was deliberately held back to 0.4.x. It is compatible with SAM and is effectively free at low volume, but it adds a second function, a queue, a DLQ, and an event-source mapping that long-polls continuously — more moving parts to reason about than the “public endpoint MVP” goal warrants. That analysis lives in the durability section of Production Hardening so the A-vs-B decision is ready when 0.4.x starts.

Hard constraints / non-goals

  • No always-on or fixed-cost resource. Every 0.3.x change is either HTTP API configuration or in-process logic. Nothing new is provisioned; nothing runs while idle.
  • No new runtime dependency beyond gopkg.in/yaml.v3. The handler changes use only the standard library and the AWS SDK clients already present.
  • Backward compatible. Every config valid at the end of 0.2.x (Form Config v1) stays valid. spam: and per-output on_error are new optional blocks — a config that omits them behaves exactly as it does today.
  • The Taskfile is the only tooling interface; go/sam run only in containers.

Execution model — phased red/green/verify

Same discipline as Form Config v1: each red/green phase is one Opus-orchestrated red/green/verify cycle (see the red-green-verify skill). Each infra phase is instead landed as a template.yaml change plus a deploy against a throwaway stage and a curl/probe check — there is no unit test for API Gateway configuration, so the “verify” step is an observed probe.

Global rules (check the parent box only once every child box, including the docs update, is checked):

  • Red fails at runtime, never at compile.
  • Off-limits to every green agent: internal/*/testdata/, every *_test.go, this plan, and go.mod/go.sum (no new deps).
  • Every phase: re-run task check (test + lint) and confirm green touched no test or fixture.
  • A phase is not done at green — the orchestrator runs the assume-bad review and only closes the phase when it finds nothing to send back.
  • Close each phase by updating docs (README + docs/), rebuilt with task build:docs.

Phase map

#ItemKindDelivers
13ainfraCorsConfiguration on the HTTP API, origins as a SAM param
21ainfraDefaultRouteSettings throttle on the HTTP API, as SAM params
32ared/greenper-output required / on_error; non-required failures don’t 500
41bred/greenper-form spam.honeypot; filled honeypot → silent 200, no relay

Suggested order: the two infra phases first (smallest change, immediate risk reduction for a public endpoint), then the two handler phases.

Phase 1 — CORS config (3a, infra)

  • Phase 1 complete (awaiting deploy-probe verification)
    • Add an AllowedOrigins SAM parameter (comma-separated list) and a CorsConfiguration on the HTTP API in template.yaml: AllowOrigins from the param, AllowMethods: [GET, POST, OPTIONS], AllowHeaders: [content-type, hx-request, hx-target, hx-current-url, …] (the htmx request headers, so cross-origin htmx — whose non-simple HX-* headers force a preflight — passes; GET covers the planned htmx form/token endpoints). API Gateway answers the OPTIONS preflight automatically. The implicit API was promoted to an explicit AWS::Serverless::HttpApi (default $default stage, URL unchanged); sam validate --lint passes.
    • Verify (probe): deploy to a throwaway stage; a cross-origin fetch from an allowed origin succeeds and reads the response; an OPTIONS preflight returns the Access-Control-Allow-Origin header; a disallowed origin is not reflected back. (Requires AWS credentials — probe commands are in the Getting Started guide.)
    • Done when: an allowed origin can fetch-submit and read the JSON response; a disallowed origin cannot read it. (Note: CORS restricts reading the response in a browser, not the submission itself — documented in the hardening plan’s CSRF section.)
    • Update docs: CORS parameter + fetch vs full-page-POST guidance in the deploy/how-to docs; CHANGELOG.md bullet; docs rebuild.

Phase 2 — Stage throttling (1a, infra)

  • Phase 2 complete (awaiting deploy-probe verification)
    • Add ThrottleRateLimit and ThrottleBurstLimit SAM parameters and set DefaultRouteSettings (ThrottlingRateLimit / ThrottlingBurstLimit) on the HTTP API in template.yaml. This caps total volume across all clients (HTTP API has no per-IP usage plans — that is REST-API/WAF territory, deferred to 0.4.x). sam validate --lint passes.
    • Verify (probe): deploy to a throwaway stage; a burst curl loop above the configured rate receives 429s once the limit is exceeded, and normal-rate requests still succeed. (Requires AWS credentials — burst-curl probe is in the Getting Started guide.)
    • Done when: sustained requests over the configured rate are throttled (429); a config that leaves the params at their defaults still deploys.
    • Update docs: throttle parameters + guidance on picking limits; CHANGELOG.md bullet; docs rebuild.

Phase 3 — Per-output on_error (2a, red/green)

  • Phase 3 complete

    Schema decision (locked with the user): the policy is spelled on_error: fail | continue on each output — not required. required stays a field property feeding input validation (→ 422); on_error is a separate delivery policy. This keeps one meaning per key and is forward compatible with the 0.4.x durability plan extending on_error with store/retry.

    • Red — tests locking the new semantics on relay.Send (internal/relay/relay.go): an output with on_error: continue that fails is logged and swallowed, and Send returns nil when only continue-policy outputs failed; a default (fail) output that fails still returns an error. Cover: all-succeed, one-continue-fails (nil), one-fail-fails (error), mixed. The config parser accepts on_error, normalizes omitted → fail, and rejects any other value. Plus handler regression locks: missing-required and wrong-type submissions return 422, not 500.
    • Green — added OnError string to config.Output/rawOutput, normalized+validated in Parse (bad value → form %q: output #%d: invalid on_error %q), and reworked relay.Send to attempt every output but return an error only when a fail-policy output failed (continue failures logged via log.Printf). Two review-driven restarts followed: the JSON Schema generator gained an on_error enum (golden + docs/static/config.schema.json regenerated) so a valid on_error config isn’t rejected by additionalProperties:false; and an unknown output type was made to always surface (500) even under continue, since it’s a misconfiguration, not a delivery failure.
    • Verify + reviewtask check green (independent Haiku run); tests/ goldens (internal/form/testdata/) untouched. Adversarial review confirmed fan-out/default-fail/logging unchanged and case-sensitive per-output validation (Continue/ continue/output #1 all handled), and validated via Python jsonschema that the schema accepts on_error: continue and rejects bogus. It caught the unknown-type-under-continue gap, fixed test-first above.
    • Done when: a submission where one continue output fails returns 200 with the failure logged; a fail-policy output failure still returns an error (→ handler 500); an unknown type always 500s. Existing relay tests stay green. Met.
    • Update docs: on_error partial-failure policy documented in the output reference (behavior table, the 422-vs-500 separation, unknown-type rule) and the HTTP API status table; CHANGELOG.md bullet; docs rebuild.

Phase 4 — Honeypot (1b, red/green)

  • Phase 4 complete
    • Red — handler tests: with a form-level spam.honeypot: <field> configured, a submission whose honeypot field is non-empty returns 200 {"message":"ok"} without calling the relayer (asserted against a fake relayer that records calls); an empty/absent honeypot field relays normally; the honeypot field is stripped from the submission before validation/relay so it never reaches an output. Config parsing accepts the spam block and rejects an unknown key inside it (strict KnownFields).
    • Green — added a Spam{Honeypot string} block to config.Form (rawForm), wired through Parse; in handler.Handle, before the captcha block, if f.Spam.Honeypot != "" the handler reads (via input.All, any-non-empty), Deletes the field, and short-circuits to the normal 200 on a filled trap. Two review-driven restarts followed: a parse-time guard rejecting a honeypot that collides with a declared field (which would otherwise strip a real field and permanently 422 the form), and a spam $def added to the JSON Schema generator (golden + docs/static/config.schema.json regenerated) so a valid spam: config isn’t rejected by additionalProperties:false.
    • Verify + reviewtask check green (independent Haiku run); no internal/form/testdata/ golden touched. Adversarial review confirmed the drop byte-matches a real success (same 200 body), the field is stripped on the normal path too, the log line leaks nothing (form name only), an absent honeypot key is panic-safe, and a form without a spam block is unaffected; Python jsonschema confirmed the schema now accepts a spam config. It caught the field-collision and schema gaps, fixed test-first above; stale step-number comments in the handler were tidied.
    • Done when: a filled-honeypot submission is silently accepted (200) and never relayed; a clean submission is unaffected; the honeypot field never appears in any output. Met.
    • Update docs: spam.honeypot config + a rendering note (visually hidden, autocomplete="off", tabindex="-1" in the embedding page) in the Spam prevention reference; CHANGELOG.md bullet; docs rebuild.

Definition of done

  • [~] CORS is configurable via a SAM origin allowlist; an allowed origin can fetch-submit and read the response, a disallowed one cannot. (Template + AllowedOrigins param landed and sam validate --lint clean; browser fetch/OPTIONS probe awaits a deploy — commands in the deploy how-to.)
  • [~] The HTTP API stage throttles sustained over-rate traffic (429), capping total cost; limits are SAM parameters. (Template + throttle params landed and lint-clean; burst-curl 429 probe awaits a deploy.)
  • A continue-policy output failure returns 200 (logged); a fail-policy output failure still 500s; default behavior is unchanged. (Spelled on_error, not requiredrequired stays field-level input validation → 422.)
  • A filled honeypot is silently dropped (200, no relay); a form without a spam block behaves exactly as before.
  • No new always-on or fixed-cost AWS resource was introduced (CORS + throttle are HTTP-API config; on_error + honeypot are in-process); the milestone stays within the perpetual free tier.
  • docs/ and CHANGELOG.md reflect shipped behavior; task build:docs succeeds. (README needs no change — no new CLI/user-facing surface; the new config keys are documented in the reference.)
  • Each red/green phase (3, 4) landed via an Opus-orchestrated red/green/ verify cycle whose assume-bad review sent findings back until clean; the two infra phases (1, 2) are lint-validated and carry a documented deploy+probe as their remaining verification.