Changelog

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

Added

  • deploy/bootstrap-policy.json and task aws:bootstrap-policy / task relay:aws:bootstrap-policy: a least-privilege policy for the ADMIN_* bootstrap identity, so nothing in this flow requires a root or full-admin key. It grants IAM user management and access-key management scoped to the deploy user’s own name (DeployUser and DeployUserAccessKeys statements) — the entire set aws:bootstrap-user (CreateUser/PutUserPolicy/CreateAccessKey) and aws:update-deploy-policy (GetUser/PutUserPolicy) actually use — and nothing else, so one bootstrap identity serves both the initial setup and every later policy refresh. aws:update-deploy-policy now checks the deploy user is readable before rendering, and says which of the three causes it is (user absent, named something else, or not visible to the ADMIN_* identity) instead of surfacing a bare API error. The task renders it for the project’s DEPLOY_USER_NAME and prints it to stdout; it needs no credentials, so it can be run before any AWS identity exists (task aws:bootstrap-policy > bootstrap-policy.json, then attach it to a new IAM user in the console). Getting Started gains a “The bootstrap identity” section covering the console steps, a statement-by-statement table, the alternative of creating the deploy user by hand with no bootstrap identity at all, and the caveat that IAM lets an identity grant permissions it does not itself hold — this is a smaller blast radius than root, not a sandbox.

  • task aws:update-deploy-policy / task relay:aws:update-deploy-policy: re-applies deploy/deploy-policy.json to an existing deploy user without touching its access keys. aws:bootstrap-user chains create-user && put-user-policy, so on an account where the user already exists it fails at the first command and the policy is never refreshed — there was no way to pick up a policy change short of deleting the user. Set DEPLOY_USER_NAME in .env.project if the deploy user is not named webform-relay-deploy.

  • “Recovering from a failed deploy” in Getting Started: why a first-time create failure leaves a stack in ROLLBACK_COMPLETE/ROLLBACK_FAILED that cannot be updated, and the fix-permission → teardown → redeploy sequence that clears it, including the console Retain path when deletion itself is blocked.

  • Split deployment config into two dotenv files: .env (gitignored) now holds only secrets — the ADMIN_AWS_* / AWS_* access keys and FORM_TOKEN_SECRET — and a new committed .env.project holds every non-secret stack parameter (AWS_DEFAULT_REGION, CONFIG_BUCKET, CONFIG_KEY, SAM_ARTIFACTS_BUCKET, STACK_NAME, CACHE_TTL, SES_FROM_ADDRESS, ALLOWED_ORIGINS, THROTTLE_*, SUBMISSIONS_STACK_NAME). Which region, bucket, and stack a project deploys to is a fact about the project, not a secret, and belongs in version control. The declaration order is dotenv: ['.env', '.env.project']: Task takes the first file that sets a key, so .env overrides the committed defaults and a shell export overrides both, and a missing file is ignored (a checkout with no .env still runs every task that needs no credentials). .gitignore’s .env pattern is exact, so it does not match .env.project.

  • A complete, non-developer S3 storage walkthrough in Fan-out: email, S3, and Salesforce Web2Lead: what task relay:deploy:submissions creates on your behalf (a private, encrypted bucket, a write-only API Gateway endpoint in front of it, its IAM role and API key — no bucket to create by hand, no code to write), then six numbered steps from deploy through reading submissions back, plus what the stored objects look like, costs and retention, a symptom/cause/fix table for the ways it fails, and teardown. Documents the trap that an http_post output sends only what its mapping lists — an output with no mapping stores an empty object per submission — and corrects the recipe’s example, which used an Authorization: Bearer header where the shipped endpoint requires x-api-key. The hand-rolled proxy template is kept below it as an explicitly optional alternative. The page’s task build:cli / bin/webform-relay commands, which do not exist in a project that vendors webform-relay, are replaced with GET /api/v1/form/{form} and task relay:validate:config.

  • Getting Started, replacing “Deploy to AWS” (docs/content/how-to/deploy.md), rewritten from the perspective that webform-relay is a vendored subproject of your own repo rather than something you clone: install v, v add the source, include its taskfile from your own Taskfile.yml, copy both dotenv templates out of the vendored subdirectory, bootstrap, deploy, and v update to move between releases. New step 3 covers the includes: wiring in detail (why dotenv: must be top-level, why no vars: forwarding is needed, that dir: points at the vendored source root and not its taskfiles/ subdirectory), and a new “Two directories, not one” section explains the {{.TASKFILE_DIR}} / {{.ROOT_DIR}} split the deploy tasks are built around.

  • A troubleshooting section in Getting Started for the two wiring mistakes that make a correctly filled-in dotenv file look empty: a top-level Taskfile.yml with the relay: include but no dotenv: line (Task reads dotenv only from the top-level taskfile and silently ignores it in an included one), and running task from inside the vendored directory (which carries webform-relay’s own root Taskfile.yml, declaring its own dotenv and the same relay: namespace, so the tasks run but resolve .env/.env.project in the wrong directory). Every “is not set” precondition message now names the first cause and the fix inline.

  • Conditional fields (show_when) in the form config (0.4.x): any element may declare a show_when clause that makes it visible only when another field’s value meets a condition — a single { field, <operator>: value } or an all/any group, with operators equals, not_equals, one_of, not_one_of, filled, empty. Evaluation is entirely server-side: the form is re-rendered over htmx when a controlling field changes (each controller is rendered with the htmx re-render attributes, and GET /api/v1/form/{form} reads the current values from the query string to repopulate and re-evaluate). A field whose condition isn’t met is not validated (a hidden required field never blocks the submission); a hidden data field collapses to a value-preserving hidden input (so a field can hide itself based on its own value and stay stable — the basis of a reveal-on-correct-entry gate), while a hidden html element is omitted. show_when.field must reference a declared input field (self-references allowed); this is validated at config load, and the config JSON schema includes show_when. See the Field Model reference and the reveal-on-correct-entry how-to.

  • Static HTML and element wrapping in the form config (0.4.x): a new type: html element renders its content verbatim at its position among the fields — it carries no data (no name, skipped by validation and submission) and rejects data-field properties (required/options/validate/…). Separately, every element (input or html) gains three optional decoration keys: prefix and suffix (verbatim strings emitted immediately before/after the element) and wrapper (a Go text/template that wraps the element — {{ .Element }} is the rendered HTML and {{ .Field }} its metadata). Composition is prefix + wrapper(element) + suffix. A wrapper with invalid syntax fails at config load; a valid template with a bad reference degrades gracefully (element rendered un-wrapped, error logged) rather than failing the request. All four keys (content/prefix/suffix/wrapper) are in the JSON schema. These render raw config-authored HTML — a trusted-markup sink; submitted user values are always escaped. See the Field Model reference.

  • Form renderer value repopulation (0.4.x): a new form.RenderHTMLWithOptions / RenderOptions{Hidden, Values} re-renders a form pre-filled with the submitter’s values — text-like and textarea values, and the selected/checked state of select/radios/checkboxes/single checkbox — overriding config defaults, so a server-side re-render preserves what the user entered. RenderHTML/RenderHTMLWithHidden delegate to it and their output is byte-for-byte unchanged. This is the foundation for htmx form re-renders (inline validation errors, and later conditional fields).

  • htmx-native submit responses (0.4.x): the POST /api/v1/submit/{form} endpoint now content-negotiates on the HX-Request header htmx sends. A request from htmx receives an HTML fragment it can swap into the page; every other client receives the exact same JSON as before (no breakage for existing consumers). On success the fragment is a per-form confirmation message (new optional form-level key, default Thank you.), and the honeypot / min-fill silent drops return the byte-identical confirmation fragment so a bot still can’t distinguish a drop from a real delivery. Validation failures re-render the form with the submitter’s values repopulated and each failing field’s message shown inline (<p class="field-error">, wired with aria-invalid/aria-describedby), plus a fresh min-fill token when the form uses one — so htmx swaps the corrected form straight back (backed by form.ValidateFields and the renderer’s value/error repopulation). The min-fill expired case and other 4xx/5xx errors return an escaped <ul class="form-errors"> fragment. Every response carries its real HTTP status (load the htmx response-targets extension to swap non-2xx into a target). Non-htmx JSON keeps the first-error-only message and unchanged status codes. See the htmx-native Responses plan.

  • CORS support on the submit endpoint (Public Endpoint MVP, Phase 1): a new AllowedOrigins SAM parameter (comma-separated origin allowlist, default *) drives a CorsConfiguration on the HTTP API, so browser fetch/XHR embeds can read the response and origins can be restricted. The HTTP API is now an explicit AWS::Serverless::HttpApi resource (default $default stage, so the endpoint URL is unchanged). AllowMethods covers GET/POST/OPTIONS and AllowHeaders includes the htmx request headers (HX-Request, HX-Target, HX-Current-URL, etc.) so cross-origin htmx requests — whose non-simple HX-* headers trigger a preflight — pass. Note: CORS restricts who may read the response in a browser, not who may POST — see the deploy how-to.

  • Honeypot spam trap (Public Endpoint MVP, Phase 4): an optional per-form spam.honeypot: <field> block names a decoy field that real users leave empty. If that field arrives with any non-empty value, the submission is silently dropped — the endpoint returns the same 200 {"message":"ok"} as a real success and nothing is relayed — so a bot can’t distinguish a drop from a delivery. The honeypot field is always stripped from the submission before validation, mapping, and relay (on both the drop and normal paths), and the check runs before captcha verification so a tripped honeypot costs nothing downstream. A honeypot name that collides with a declared field is rejected at config-parse time, and the config JSON schema now includes the spam block. See the Spam prevention reference.

  • Min-fill signed-token spam check (0.4.x): a stateless, HMAC-signed timestamp that rejects submissions completing implausibly fast, with no server-side state. An internal/token engine mints crypto/hmac SHA-256 tokens bound to the form id (standard library only, injectable clock). Three per-form spam: keys configure it — min_fill_seconds (>0 enables the check), form_ttl (token max age, default 2h), and token_field (hidden field name, default _ts) — parsed with defaults, a window-sanity check (form_ttl must exceed min_fill_seconds), a negative guard, a token_field-vs-declared-field collision guard, and a JSON-schema $def. A fresh token is issued per form load by two htmx-native GET endpoints (routed internally): GET /api/v1/form/{form} server-renders the whole form with the token injected, and GET /api/v1/token/{form} returns just the <input type="hidden"> token fragment for a site that owns its own markup. On submit, the token field is stripped, then a missing/forged/expired-signature token or an elapsed time below min_fill_seconds is a silent 200 drop (no relay, indistinguishable from a real success), an elapsed time beyond form_ttl returns 422 {"error":"form expired, please reload"}, and a valid in-window token proceeds. The HMAC secret comes from the FORM_TOKEN_SECRET env var (new FormTokenSecret NoEcho SAM parameter), never the S3 config; a min-fill form served or submitted without the secret fails closed with 500 (checked per request, so it stays correct across config hot-reloads). CORS already permits GET and the htmx headers for the new endpoints. Remaining verification is a live deploy probe (load → wait → submit); for stronger secret handling, resolve FORM_TOKEN_SECRET from SSM/Secrets Manager. See the Min-Fill Signed Token plan and the Spam prevention reference.

  • Per-output on_error delivery policy (Public Endpoint MVP, Phase 3): each output may set on_error: fail (default) or on_error: continue. With continue, a delivery failure on that output is logged and swallowed instead of failing the whole submission, so one dead best-effort destination (e.g. a secondary webhook) no longer returns 500 while the others succeed. The default fail preserves the historical all-or-nothing behavior. An unknown/unregistered output type always surfaces (500) regardless of policy, and any on_error value other than fail/continue is rejected at config-parse time. This is a delivery policy only — field required and input validation still return 422, never 500. The JSON config schema now includes on_error. See the output reference.

  • Stage throttling on the submit endpoint (Public Endpoint MVP, Phase 2): new ThrottleRateLimit (default 10 req/s) and ThrottleBurstLimit (default 20) SAM parameters set DefaultRouteSettings on the HTTP API stage, capping total request volume across all clients so a single client cannot drive unbounded Lambda/SES/downstream cost. Over-rate requests receive 429. This is a global cap, not per-IP (per-IP needs AWS WAF, a later hardening step).

  • Containerized, .env-driven deployment (taskfiles/deploy.yml): the deploy-facing tasks (build:container, deploy:*, logs:*, aws:*, submissions:*, upload:config, validate:config, info, clean:build, clean:tools) live in a separate, self-contained Taskfile with no dotenv: declaration, meant to be vendored into other projects (e.g. via v) and consumed via Task’s includes: — see Vendoring alternatives. Taskfile.yml includes: it under the relay: namespace, so this repo exercises the same pattern downstream projects use. The whole flow runs in Docker with credentials in a gitignored .env and stack config in a committed .env.project (no host AWS/SAM CLI, no samconfig.toml); task aws:bootstrap creates the least-privilege deploy user and S3 buckets — see Getting Started.

  • Taskfile split into taskfiles/dev.yml (build, test, lint, format, docs/site, local SAM API) and taskfiles/deploy.yml (everything deploy-facing), with the root Taskfile.yml reduced to the dotenv: declaration and an includes: block for both — dev flatten: true so its tasks keep their unprefixed names (task test:all), deploy under relay:. Task v3.39.0+ is now required (includes: flatten landed there). Because taskfiles/deploy.yml no longer sits at the source root, RELAY_SOURCE_DIR resolves through cd '{{.TASKFILE_DIR}}/..' && pwd instead of {{.TASKFILE_DIR}} — every docker mount still gets a clean absolute path to the source root. Consumers’ includes: entries point at <source>/taskfiles/deploy.yml, and Getting Started gains a step 1 covering that wiring (a no-op for a direct clone, the required step for any other project); its later sections renumber by one.

  • task lint:security (plus task lint:gosec and task lint:govulncheck individually): runs CI’s security stage locally, in the same golang:1.23 container and at the same pinned tool versions (GOSEC_VERSION/GOVULNCHECK_VERSION in Taskfile.yml, mirroring .gitlab-ci.yml), so a CI security warning is reproducible without installing Go, gosec, or govulncheck on the host. Scanner binaries are cached in .cache/go/bin via a new GO_TOOL_MOUNTS (a writable /go/bin on top of GO_MOUNTS), so only the first run pays the go install. Deliberately excluded from lint:all and check, matching CI, where both jobs are allow_failure until a clean baseline exists.

  • task deploy:rollback / task relay:deploy:rollback: rolls the app stack back to its last successfully deployed state (cloudformation rollback-stack), waits for the rollback to reach a terminal state, and prints the resulting stack status — all inside the tools container, no host AWS CLI. The deploy policy gains cloudformation:RollbackStack and cloudformation:ContinueUpdateRollback (the latter for a hand-driven recovery from UPDATE_ROLLBACK_FAILED, which has no task wrapper since it usually needs --resources-to-skip chosen by hand). A config-only change has nothing to roll back — re-upload the previous YAML with upload:config instead. See Getting Started.

  • THROTTLE_RATE_LIMIT, THROTTLE_BURST_LIMIT (in .env.project) and FORM_TOKEN_SECRET (in .env, see .env.example), passed through to the ThrottleRateLimit/ThrottleBurstLimit/FormTokenSecret stack parameters by task deploy:app. Previously these three took template defaults on every non-guided deploy, so a form using spam.min_fill_seconds could not be deployed with a working secret through task deploy:app at all (it fails closed with 500 without one). FORM_TOKEN_SECRET is passed conditionally: blank in .env, it is omitted from sam deploy so an existing stack keeps the secret it already has rather than having it blanked. The dotenv key — and which of the two files it belongs in — for every stack parameter is now listed in the SAM template parameters reference.

  • Documented all three ways to consume webform-relay from another project (Vendoring alternatives): v (recommended and supported), a plain copy of a release tarball, and a git submodule — with a comparison table up front, the includes: block for each, and the caveats that actually bite (a copy records no version and silently loses local edits; a submodule leaves a plain git clone empty and needs GIT_SUBMODULE_STRATEGY: recursive / submodules: recursive in CI, and update --remote can move you onto an unreleased commit). Only the taskfile:/dir: pair differs — {{.TASKFILE_DIR}}/{{.ROOT_DIR}} make every task behave identically in all three.

  • Documented deploy-flow limitations (Getting Started): a new “Limitations and known gaps” section covers secret handling (plaintext static keys in .env, FORM_TOKEN_SECRET on the sam deploy command line and as a NoEcho stack parameter, no SSM/Secrets Manager/OIDC path, bootstrap printing the access key to stdout), the single-.env.project/single-environment model (no ENV= selector; STACK_NAME is the only separation), throttling being global rather than per-IP, what deploy:rollback does not cover, why teardown is deliberately partial, and the tested surface (one account, us-east-1). Cross-linked from the how-to index and Vendoring alternatives.

  • Documented the S3 submissions storage relay (Getting Started, section 8): deploy:submissions, submissions:api-key, submissions:list/read/dump, deploy:submissions-teardown and its non-empty-bucket failure. The fan-out how-to now points at the shipped deploy/s3-relay-template.yaml instead of leading with a hand-rolled API Gateway S3 proxy (that template is kept below it for anyone who wants to own it), and its prerequisites reference deploy:app rather than deploy:guided.

  • task info / task relay:info: lists every form in the live (or local, if not yet uploaded) config, whether the app stack is deployed, and each form’s test endpoint URL.

  • task upload:config / task relay:upload:config: uploads a config file to CONFIG_BUCKET/CONFIG_KEY via the dockerized tools image, no local AWS CLI needed.

  • task submissions:list/submissions:dump/submissions:read / relay: equivalents: list and read stored submissions from the optional S3 storage relay (deploy/s3-relay-template.yaml), URL-decoded.

  • Enriched field model recognized by the config parser (Form Config v1, Phase 1): eight new field types (url, tel, number, date, select, radios, checkboxes, checkbox) alongside the existing text/email/textarea/hidden; new common properties on any field (label, label_hidden, help, hint, hint_position, placeholder, default, disabled, readonly, hidden, autocomplete, attributes, validate, messages); and new type-specific properties (rows, min/max/step, multiple/empty_label, inline, options). Constraint enforcement, template rendering, and rich HTML rendering for these types land in later phases; see the Field Model reference.

  • Structural validation of field definitions: reserved field names (id, form_id, submitted_at, handler), options on a non-choice type, a choice type with no options, numeric/multiple/rows/inline/empty_label properties on the wrong field type, and a hidden field with no default are all rejected at config-parse time with an error naming the offending form, field, and property.

  • Full normalization of options on select/radios/checkboxes fields (Form Config v1, Phase 2): shorthand scalar lists (options: [small, medium, large]) and explicit maps ({value, label, selected, disabled}) may be freely mixed in one list, and an omitted label defaults to value. Grouped options ({group: ..., options: [...]}) and options_from are rejected as “not supported in v1”; duplicate option values, unknown keys inside an option map, an empty/null option value, and an option entry that is neither a scalar nor a map are all rejected at config-parse time with an error naming the form and field.

  • Multi-value submission model (Form Config v1, Phase 3): fields submitted more than once (e.g. checkboxes and multi-select, which POST a repeated key like interest=design&interest=engineering) are now preserved end to end — submission parsing, validation, field mapping, and every relay output — instead of collapsing to the first value. http_post/salesforce_web2lead send repeated keys in the URL-encoded body; email/smtp emit one key=value line per value. A required field is satisfied if any submitted value is non-blank, the 10 000-byte length limit is enforced per value, and an email-type field’s format is checked against its first non-blank value. See the Field Model reference.

  • Server-side enforcement of the validate: constraint block and messages: map (Form Config v1, Phase 4): min_length/max_length (character count), min/max (numeric for number, ISO-string for date), step (number only, based at 0), pattern (RE2, matched against the entire value, rejected at parse time if invalid), one_of, min_selected/max_selected (count of non-blank values, enforced even on an optional field), and matches_field/differs_from are now checked against each submitted value. A blank optional field skips the per-value constraints but not min_selected/max_selected; each failure uses the field’s messages.<constraint> verbatim if set, else a default message naming the field. See the Field Model reference.

  • Restricted server-side template engine for output config values (Form Config v1, Phase 5): an output’s subject, to, each static value, each headers value, and username/password may now contain {{ ... }} templates, rendered at submission time with Go’s standard-library text/template (url and mapping are never templated). Templates see .fields.<name> (submitted value(s), joined with , for multi-value), .form.id, and .submitted_at, plus a function whitelist: trim, lower, upper, default, env (the canonical secret mechanism — named environment-variable lookup only, no enumeration of the environment), values, and join. A template syntax error or unknown function fails config load rather than the next submission, and rendered output is not HTML-escaped (these are email/webhook sinks). See the Templating reference.

  • Accessible HTML rendering of the full enriched field model (Form Config v1, Phase 6): form.RenderHTML (and webform-relay form html <name>) now renders every field type and property — text-like fields/select/textarea in a <div class="field"> with a <label for="{name}">; radios/checkboxes as <fieldset class="field"><legend> with one labelled <input> per option; single checkbox inline; hidden (type or hidden: true) as a bare unwrapped <input type="hidden">. help/hint render as <p class="help">/<span class="hint"> wired to the control via aria-describedby; label_hidden keeps the label but visually hides it; custom attributes merge in with sorted keys; all interpolated values are HTML-escaped and output is byte-stable across renders. Group required (radios/checkboxes) is enforced server-side via validate: rather than a client-side attribute, and custom attributes keys are validated against a safe attribute-name pattern at config-parse time. See the Field Model reference.

  • Captcha/spam-prevention support: server-side token verification for reCAPTCHA v2, reCAPTCHA v3, hCaptcha, and Cloudflare Turnstile. Configured per-form via a captcha block in the YAML config (provider, secret, min_score, field). Captcha tokens are stripped from the submission before relay.

  • SMTP relay output type: send form submissions as email via an external SMTP server. Supports SMTPS (port 465) and STARTTLS (port 587), with optional authentication.

  • CONFIG_INLINE and CONFIG_FILE environment variables as higher-priority alternatives to S3 for loading the form config — useful for local development and testing.

  • ConfigInline SAM template parameter wiring CONFIG_INLINE into the Lambda environment.

  • SAM local development improvements: --host 0.0.0.0 binding, Docker-in-Docker bridge networking via --container-host/--container-host-interface, and optional ENV_VARS flag on task start:api.

  • How-to guides for SMTP relay setup and Salesforce Web2Lead form relay.

  • Reference documentation for the SMTP output type, hidden field type, and inline/file config sources.

  • E2E test infrastructure: test/e2e/testdata/env-vars.json and test/e2e/testdata/config.yaml for running the full stack locally without S3.

Changed

  • The Contribution Guide is now the home of the local-development flow (clone, task test:all / lint:all / fmt:code, running the API locally with CONFIG_INLINE via task start:api ENV_VARS=..., the docs/site build), moved out of the deployment page and the quickstart. Vendoring alternatives drops the v walkthrough that Getting Started now owns and covers only the plain-copy and git-submodule paths and what each costs.

  • CONFIG= (the local config path taken by upload:config / validate:config, resolved against your project directory) now defaults to CONFIG_KEY instead of the literal webform.yaml. The two are distinct by design — CONFIG is the source path, CONFIG_KEY the destination S3 key baked into the stack — but they hold the same value in the common case, which previously still had to be typed on every invocation for any project not using the default filename.

  • Breaking: config parsing is now strict — a form, field, output, or captcha block with an unknown or misspelled key (e.g. requried instead of required) now fails to parse instead of silently ignoring it. Run webform-relay config validate against your config after upgrading.

  • Breaking: deploy-facing task names now require a relay: prefix (task deploy:apptask relay:deploy:app, task aws:bootstraptask relay:aws:bootstrap, etc. — the full list is every task documented in Getting Started), as a consequence of moving them into taskfiles/deploy.yml (see Added, above). Everything else (build:app, test:*, lint:*, start:api, generate:*, …) is unchanged.

  • Deployment now uses a plain, self-created S3 bucket (SAM_ARTIFACTS_BUCKET in .env.project, created by task relay:aws:bootstrap-artifacts-bucket) for sam deploy build artifacts instead of sam deploy --resolve-s3’s auto-managed bucket. The auto-managed flow provisions its own CloudFormation stack (aws-sam-cli-managed-default) with a bucket policy, versioning, encryption config, and an SSM parameter — each needing its own IAM permission, and each a way for that stack to get stuck in ROLLBACK_FAILED if any one permission is missing.

  • All AWS credentials and deploy-time SAM parameters now come from dotenv files — credentials in a gitignored .env (see .env.example), stack parameters in the committed .env.project — rather than ~/.aws/credentials or interactive sam deploy --guided prompts. task relay:deploy:app is fully non-interactive.

Fixed

  • Every repository URL and vendor path in the docs, README, openapi.yaml, taskfiles/deploy.yml, and the docs site’s navigation pointed at github.com/frob/webform-relay; the project is hosted on GitLab. v add/v update commands, the includes: blocks, the cp commands for the dotenv templates, and the vendor directory in every example are now gitlab.com/frob/webform-relay — a consumer following the old instructions vendored to vendor/github.com/... and had to hand-edit every path. The release-tarball URL and the openapi.yaml link use GitLab’s /-/archive/ and /-/blob/ forms. The Go module path (module github.com/frob/webform-relay in go.mod, and every import derived from it) is deliberately unchanged — it is an import path, not a location.

  • Getting Started gains a GitLab CI deployment example alongside the GitHub Actions one, including the Docker-in-Docker service the tasks need and where the credentials come from.

  • task deploy:app validates ALLOWED_ORIGINS before deploying. Each entry has to be a full origin — scheme, host, optional port — and API Gateway rejects a bare hostname (www.example.com), a trailing slash, or a space after a comma with Invalid format for origin. That rejection arrived only while CloudFormation was creating the HTTP API, minutes in, and left a ROLLBACK_COMPLETE stack that had to be torn down before the next attempt; the precondition now fails in a second and shows what the value should look like.

  • The deploy user’s policy is attached as a customer-managed policy rather than an inline user policy. Inline user policies are capped at 2048 characters and deploy-policy.json is ~3.4KB, so both aws:bootstrap-user and aws:update-deploy-policy failed with LimitExceeded: Maximum policy size of 2048 bytes exceeded — the shipped policy has been over that limit for as long as it has existed. deploy/apply-deploy-policy.sh now creates the policy (or adds a new default version to it), attaches it to the user, prunes non-default versions to stay under the five-version cap, and clears any leftover inline policy of the same name. The bootstrap policy gains a DeployPolicy statement (create/version/tag/delete, scoped to arn:aws:iam::*:policy/<deploy user>) and AttachUserPolicy/DetachUserPolicy/ListAttachedUserPolicies on the user — re-run task aws:bootstrap-policy and update the policy attached to the bootstrap identity before re-running either task.

  • task aws:bootstrap-user never worked as written: inside its sh -c body the wrapped aws iam put-user-policy invocation had its --user-name / --policy-name / --policy-document flags on more-indented lines, and a YAML folded scalar (>-) preserves the newline before a more-indented line — so each flag ran as its own shell command (sh: --user-name: not found, exit 127). Both IAM tasks now use literal (|) blocks, where every newline is explicit, and the wrapped commands are single lines.

  • task aws:bootstrap-user on an account where the deploy user already exists reported a bare EntityAlreadyExists; it now says so and points at aws:update-deploy-policy for refreshing the policy. An AccessDenied on iam:CreateUser — the bootstrap identity’s policy having been rendered for a different DEPLOY_USER_NAME — now explains that and gives the re-render command instead of failing opaquely.

  • Precondition messages printed dotenv: [''.env'', ''.env.project''] — doubled apostrophes, from single-quote escaping applied inside an already double-quoted YAML scalar. 24 messages affected.

  • The deploy user’s IAM policy no longer assumes STACK_NAME contains webform-relay. Its IAMRoles/IAMPassRole statements were scoped to arn:aws:iam::*:role/*webform-relay*, but CloudFormation names the roles it generates after the stack — so any project deploying with, say, STACK_NAME=acme-forms got User ... is not authorized to perform: iam:CreateRole partway through deploy:app or deploy:submissions, and the failed stack then stuck in ROLLBACK_FAILED because deleting the half-created role was denied by the same statement. The policy is now rendered per project at bootstrap time (deploy/render-policy.py deploy/deploy-policy.json --roles <stack>), adding role ARNs matching the configured STACK_NAME alongside the default pattern; task aws:update-deploy-policy re-applies it after a STACK_NAME change. Stack names longer than the ~34 characters CloudFormation truncates a generated role name to are still not covered — keep them short.

  • AWS credentials no longer appear in the commands Task echoes to the terminal. RELAY_AWS_ENV_FLAGS interpolated the access key and secret directly into each docker run command line, putting them in terminal scrollback, shell history, ps output, and CI job logs on every deploy task. Deploy-user credentials are now passed as -e NAME (docker reads the value from the task’s own environment, which dotenv populated) and the bootstrap tasks’ ADMIN_* credentials as a shell $VAR the shell expands after Task has printed the command.

  • An unset or blank CONFIG_KEY no longer blanks the deployed stack’s config key: task deploy:app passed ConfigKey= through as an empty --parameter-overrides value, overriding template.yaml’s Default: webform.yaml with an empty string rather than falling back to it. CONFIG_KEY is now defaulted in the taskfile (RELAY_CONFIG_KEY), the same way CACHE_TTL and the throttle limits already were.

  • task build:all on arm64 hosts (Apple Silicon): the dev tools image now derives the Docker apt repo architecture from dpkg --print-architecture instead of hard-coding amd64, and the Lambda container build sources its linux/amd64 bases under distinct local tags (via the new tools:lambda-bases target) so sam build produces the x86_64 image without clobbering the native golang:1.23 tag the Go tasks use.

  • Lambda handler migrated from API Gateway v1 (APIGatewayProxyRequest/Response) to v2 HTTP API (APIGatewayV2HTTPRequest/Response), resolving “Lambda response must be valid json” errors from SAM local.

  • The handler now decodes the request body when API Gateway sets isBase64Encoded (it does this for some content types even without explicit binary media type configuration). Previously every field on a real deployment (not sam local, which never base64-encodes) could silently fail validation as “required” even when submitted correctly.

Initial

  • Initial Go Lambda handler for POST /api/v1/submit
  • SAM template with API Gateway integration
  • Taskfile with full development lifecycle tasks
  • Diataxis documentation site (Hugo + hextra)
  • Marketing brochure site (Hugo)
  • OpenAPI 3.0 specification