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:
- CORS config (3a, infra) — an origin allowlist so
fetch-based embeds work and only known sites may POST. - Stage throttling (1a, infra) — a global request cap on the HTTP API so a single client cannot drive unbounded Lambda/SES/downstream cost.
- Per-output
on_error(2a, red/green) — one dead optional output no longer 500s the whole submission. - 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_errorand 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-outputon_errorare new optional blocks — a config that omits them behaves exactly as it does today. - The Taskfile is the only tooling interface;
go/samrun 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, andgo.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 withtask build:docs.
Phase map
| # | Item | Kind | Delivers |
|---|---|---|---|
| 1 | 3a | infra | CorsConfiguration on the HTTP API, origins as a SAM param |
| 2 | 1a | infra | DefaultRouteSettings throttle on the HTTP API, as SAM params |
| 3 | 2a | red/green | per-output required / on_error; non-required failures don’t 500 |
| 4 | 1b | red/green | per-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
AllowedOriginsSAM parameter (comma-separated list) and aCorsConfigurationon the HTTP API intemplate.yaml:AllowOriginsfrom 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-simpleHX-*headers force a preflight — passes;GETcovers the planned htmx form/token endpoints). API Gateway answers the OPTIONS preflight automatically. The implicit API was promoted to an explicitAWS::Serverless::HttpApi(default$defaultstage, URL unchanged);sam validate --lintpasses. - Verify (probe): deploy to a throwaway stage; a cross-origin
fetchfrom an allowed origin succeeds and reads the response; anOPTIONSpreflight returns theAccess-Control-Allow-Originheader; 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 +
fetchvs full-page-POST guidance in the deploy/how-to docs;CHANGELOG.mdbullet; docs rebuild.
- Add an
Phase 2 — Stage throttling (1a, infra)
- Phase 2 complete (awaiting deploy-probe verification)
- Add
ThrottleRateLimitandThrottleBurstLimitSAM parameters and setDefaultRouteSettings(ThrottlingRateLimit/ThrottlingBurstLimit) on the HTTP API intemplate.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 --lintpasses. - Verify (probe): deploy to a throwaway stage; a burst
curlloop above the configured rate receives429s 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.mdbullet; docs rebuild.
- Add
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 | continueon each output — notrequired.requiredstays a field property feeding input validation (→ 422);on_erroris a separate delivery policy. This keeps one meaning per key and is forward compatible with the 0.4.x durability plan extendingon_errorwithstore/retry.- Red — tests locking the new semantics on
relay.Send(internal/relay/relay.go): an output withon_error: continuethat fails is logged and swallowed, andSendreturnsnilwhen onlycontinue-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 acceptson_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 stringtoconfig.Output/rawOutput, normalized+validated inParse(bad value →form %q: output #%d: invalid on_error %q), and reworkedrelay.Sendto attempt every output but return an error only when afail-policy output failed (continuefailures logged vialog.Printf). Two review-driven restarts followed: the JSON Schema generator gained anon_errorenum (golden +docs/static/config.schema.jsonregenerated) so a validon_errorconfig isn’t rejected byadditionalProperties:false; and an unknown output type was made to always surface (500) even undercontinue, since it’s a misconfiguration, not a delivery failure. - Verify + review —
task checkgreen (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 #1all handled), and validated via Pythonjsonschemathat the schema acceptson_error: continueand rejectsbogus. It caught the unknown-type-under-continue gap, fixed test-first above. - Done when: a submission where one
continueoutput fails returns 200 with the failure logged; afail-policy output failure still returns an error (→ handler 500); an unknown type always 500s. Existing relay tests stay green. Met. - Update docs:
on_errorpartial-failure policy documented in the output reference (behavior table, the 422-vs-500 separation, unknown-type rule) and the HTTP API status table;CHANGELOG.mdbullet; docs rebuild.
- Red — tests locking the new semantics on
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 returns200 {"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 thespamblock and rejects an unknown key inside it (strictKnownFields). - Green — added a
Spam{Honeypot string}block toconfig.Form(rawForm), wired throughParse; inhandler.Handle, before the captcha block, iff.Spam.Honeypot != ""the handler reads (viainput.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 aspam$defadded to the JSON Schema generator (golden +docs/static/config.schema.jsonregenerated) so a validspam:config isn’t rejected byadditionalProperties:false. - Verify + review —
task checkgreen (independent Haiku run); nointernal/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 aspamblock is unaffected; Pythonjsonschemaconfirmed the schema now accepts aspamconfig. 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.honeypotconfig + a rendering note (visually hidden,autocomplete="off",tabindex="-1"in the embedding page) in the Spam prevention reference;CHANGELOG.mdbullet; docs rebuild.
- Red — handler tests: with a form-level
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 +AllowedOriginsparam landed andsam validate --lintclean; 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-curl429probe awaits a deploy.) - A
continue-policy output failure returns 200 (logged); afail-policy output failure still 500s; default behavior is unchanged. (Spelledon_error, notrequired—requiredstays field-level input validation → 422.) - A filled honeypot is silently dropped (200, no relay); a form without a
spamblock 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/andCHANGELOG.mdreflect shipped behavior;task build:docssucceeds. (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.