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.jsonandtask aws:bootstrap-policy/task relay:aws:bootstrap-policy: a least-privilege policy for theADMIN_*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 (DeployUserandDeployUserAccessKeysstatements) — the entire setaws:bootstrap-user(CreateUser/PutUserPolicy/CreateAccessKey) andaws: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-policynow 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 theADMIN_*identity) instead of surfacing a bare API error. The task renders it for the project’sDEPLOY_USER_NAMEand 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-appliesdeploy/deploy-policy.jsonto an existing deploy user without touching its access keys.aws:bootstrap-userchainscreate-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. SetDEPLOY_USER_NAMEin.env.projectif the deploy user is not namedwebform-relay-deploy.“Recovering from a failed deploy” in Getting Started: why a first-time create failure leaves a stack in
ROLLBACK_COMPLETE/ROLLBACK_FAILEDthat 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 — theADMIN_AWS_*/AWS_*access keys andFORM_TOKEN_SECRET— and a new committed.env.projectholds 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 isdotenv: ['.env', '.env.project']: Task takes the first file that sets a key, so.envoverrides the committed defaults and a shell export overrides both, and a missing file is ignored (a checkout with no.envstill runs every task that needs no credentials)..gitignore’s.envpattern 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:submissionscreates 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 anhttp_postoutput sends only what itsmappinglists — an output with nomappingstores an empty object per submission — and corrects the recipe’s example, which used anAuthorization: Bearerheader where the shipped endpoint requiresx-api-key. The hand-rolled proxy template is kept below it as an explicitly optional alternative. The page’stask build:cli/bin/webform-relaycommands, which do not exist in a project that vendors webform-relay, are replaced withGET /api/v1/form/{form}andtask 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: installv,v addthe source, include its taskfile from your ownTaskfile.yml, copy both dotenv templates out of the vendored subdirectory, bootstrap, deploy, andv updateto move between releases. New step 3 covers theincludes:wiring in detail (whydotenv:must be top-level, why novars:forwarding is needed, thatdir:points at the vendored source root and not itstaskfiles/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.ymlwith therelay:include but nodotenv:line (Task reads dotenv only from the top-level taskfile and silently ignores it in an included one), and runningtaskfrom inside the vendored directory (which carries webform-relay’s own rootTaskfile.yml, declaring its own dotenv and the samerelay:namespace, so the tasks run but resolve.env/.env.projectin 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 ashow_whenclause that makes it visible only when another field’s value meets a condition — a single{ field, <operator>: value }or anall/anygroup, with operatorsequals,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, andGET /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 hiddenrequiredfield 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 hiddenhtmlelement is omitted.show_when.fieldmust reference a declared input field (self-references allowed); this is validated at config load, and the config JSON schema includesshow_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: htmlelement renders itscontentverbatim at its position among the fields — it carries no data (noname, skipped by validation and submission) and rejects data-field properties (required/options/validate/…). Separately, every element (input orhtml) gains three optional decoration keys:prefixandsuffix(verbatim strings emitted immediately before/after the element) andwrapper(a Gotext/templatethat wraps the element —{{ .Element }}is the rendered HTML and{{ .Field }}its metadata). Composition isprefix+wrapper(element)+suffix. Awrapperwith 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 andtextareavalues, and theselected/checkedstate ofselect/radios/checkboxes/singlecheckbox— overriding config defaults, so a server-side re-render preserves what the user entered.RenderHTML/RenderHTMLWithHiddendelegate 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 theHX-Requestheader 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-formconfirmationmessage (new optional form-level key, defaultThank 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 witharia-invalid/aria-describedby), plus a fresh min-fill token when the form uses one — so htmx swaps the corrected form straight back (backed byform.ValidateFieldsand 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 htmxresponse-targetsextension 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
AllowedOriginsSAM parameter (comma-separated origin allowlist, default*) drives aCorsConfigurationon the HTTP API, so browserfetch/XHR embeds can read the response and origins can be restricted. The HTTP API is now an explicitAWS::Serverless::HttpApiresource (default$defaultstage, so the endpoint URL is unchanged).AllowMethodscoversGET/POST/OPTIONSandAllowHeadersincludes the htmx request headers (HX-Request,HX-Target,HX-Current-URL, etc.) so cross-origin htmx requests — whose non-simpleHX-*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 same200 {"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 thespamblock. 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/tokenengine mintscrypto/hmacSHA-256 tokens bound to the form id (standard library only, injectable clock). Three per-formspam:keys configure it —min_fill_seconds(>0 enables the check),form_ttl(token max age, default2h), andtoken_field(hidden field name, default_ts) — parsed with defaults, a window-sanity check (form_ttlmust exceedmin_fill_seconds), a negative guard, atoken_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, andGET /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 belowmin_fill_secondsis a silent200drop (no relay, indistinguishable from a real success), an elapsed time beyondform_ttlreturns422 {"error":"form expired, please reload"}, and a valid in-window token proceeds. The HMAC secret comes from theFORM_TOKEN_SECRETenv var (newFormTokenSecretNoEchoSAM parameter), never the S3 config; a min-fill form served or submitted without the secret fails closed with500(checked per request, so it stays correct across config hot-reloads). CORS already permitsGETand the htmx headers for the new endpoints. Remaining verification is a live deploy probe (load → wait → submit); for stronger secret handling, resolveFORM_TOKEN_SECRETfrom SSM/Secrets Manager. See the Min-Fill Signed Token plan and the Spam prevention reference.Per-output
on_errordelivery policy (Public Endpoint MVP, Phase 3): each output may seton_error: fail(default) oron_error: continue. Withcontinue, 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 defaultfailpreserves the historical all-or-nothing behavior. An unknown/unregistered output type always surfaces (500) regardless of policy, and anyon_errorvalue other thanfail/continueis rejected at config-parse time. This is a delivery policy only — fieldrequiredand input validation still return 422, never 500. The JSON config schema now includeson_error. See the output reference.Stage throttling on the submit endpoint (Public Endpoint MVP, Phase 2): new
ThrottleRateLimit(default 10 req/s) andThrottleBurstLimit(default 20) SAM parameters setDefaultRouteSettingson 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 receive429. 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 nodotenv:declaration, meant to be vendored into other projects (e.g. viav) and consumed via Task’sincludes:— see Vendoring alternatives.Taskfile.ymlincludes:it under therelay:namespace, so this repo exercises the same pattern downstream projects use. The whole flow runs in Docker with credentials in a gitignored.envand stack config in a committed.env.project(no host AWS/SAM CLI, nosamconfig.toml);task aws:bootstrapcreates 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) andtaskfiles/deploy.yml(everything deploy-facing), with the rootTaskfile.ymlreduced to thedotenv:declaration and anincludes:block for both — devflatten: trueso its tasks keep their unprefixed names (task test:all), deploy underrelay:. Task v3.39.0+ is now required (includes: flattenlanded there). Becausetaskfiles/deploy.ymlno longer sits at the source root,RELAY_SOURCE_DIRresolves throughcd '{{.TASKFILE_DIR}}/..' && pwdinstead 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(plustask lint:gosecandtask lint:govulncheckindividually): runs CI’s security stage locally, in the samegolang:1.23container and at the same pinned tool versions (GOSEC_VERSION/GOVULNCHECK_VERSIONinTaskfile.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/binvia a newGO_TOOL_MOUNTS(a writable/go/binon top ofGO_MOUNTS), so only the first run pays thego install. Deliberately excluded fromlint:allandcheck, matching CI, where both jobs areallow_failureuntil 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 gainscloudformation:RollbackStackandcloudformation:ContinueUpdateRollback(the latter for a hand-driven recovery fromUPDATE_ROLLBACK_FAILED, which has no task wrapper since it usually needs--resources-to-skipchosen by hand). A config-only change has nothing to roll back — re-upload the previous YAML withupload:configinstead. See Getting Started.THROTTLE_RATE_LIMIT,THROTTLE_BURST_LIMIT(in.env.project) andFORM_TOKEN_SECRET(in.env, see.env.example), passed through to theThrottleRateLimit/ThrottleBurstLimit/FormTokenSecretstack parameters bytask deploy:app. Previously these three took template defaults on every non-guided deploy, so a form usingspam.min_fill_secondscould not be deployed with a working secret throughtask deploy:appat all (it fails closed with500without one).FORM_TOKEN_SECRETis passed conditionally: blank in.env, it is omitted fromsam deployso 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, theincludes:block for each, and the caveats that actually bite (a copy records no version and silently loses local edits; a submodule leaves a plaingit cloneempty and needsGIT_SUBMODULE_STRATEGY: recursive/submodules: recursivein CI, andupdate --remotecan move you onto an unreleased commit). Only thetaskfile:/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_SECRETon thesam deploycommand line and as aNoEchostack parameter, no SSM/Secrets Manager/OIDC path, bootstrap printing the access key to stdout), the single-.env.project/single-environment model (noENV=selector;STACK_NAMEis the only separation), throttling being global rather than per-IP, whatdeploy:rollbackdoes 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-teardownand its non-empty-bucket failure. The fan-out how-to now points at the shippeddeploy/s3-relay-template.yamlinstead 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 referencedeploy:apprather thandeploy: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 toCONFIG_BUCKET/CONFIG_KEYvia 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 existingtext/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),optionson a non-choice type, a choice type with no options, numeric/multiple/rows/inline/empty_labelproperties on the wrong field type, and ahiddenfield with nodefaultare all rejected at config-parse time with an error naming the offending form, field, and property.Full normalization of
optionsonselect/radios/checkboxesfields (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 omittedlabeldefaults tovalue. Grouped options ({group: ..., options: [...]}) andoptions_fromare 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.
checkboxesand multi-select, which POST a repeated key likeinterest=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_web2leadsend repeated keys in the URL-encoded body;email/smtpemit onekey=valueline per value. Arequiredfield is satisfied if any submitted value is non-blank, the 10 000-byte length limit is enforced per value, and anemail-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 andmessages:map (Form Config v1, Phase 4):min_length/max_length(character count),min/max(numeric fornumber, ISO-string fordate),step(numberonly, 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), andmatches_field/differs_fromare now checked against each submitted value. A blank optional field skips the per-value constraints but notmin_selected/max_selected; each failure uses the field’smessages.<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, eachstaticvalue, eachheadersvalue, andusername/passwordmay now contain{{ ... }}templates, rendered at submission time with Go’s standard-librarytext/template(urlandmappingare 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, andjoin. 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(andwebform-relay form html <name>) now renders every field type and property — text-like fields/select/textareain a<div class="field">with a<label for="{name}">;radios/checkboxesas<fieldset class="field"><legend>with one labelled<input>per option; singlecheckboxinline;hidden(type orhidden: true) as a bare unwrapped<input type="hidden">.help/hintrender as<p class="help">/<span class="hint">wired to the control viaaria-describedby;label_hiddenkeeps the label but visually hides it; customattributesmerge in with sorted keys; all interpolated values are HTML-escaped and output is byte-stable across renders. Grouprequired(radios/checkboxes) is enforced server-side viavalidate:rather than a client-side attribute, and customattributeskeys 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
captchablock 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_INLINEandCONFIG_FILEenvironment variables as higher-priority alternatives to S3 for loading the form config — useful for local development and testing.ConfigInlineSAM template parameter wiringCONFIG_INLINEinto the Lambda environment.SAM local development improvements:
--host 0.0.0.0binding, Docker-in-Docker bridge networking via--container-host/--container-host-interface, and optionalENV_VARSflag ontask start:api.How-to guides for SMTP relay setup and Salesforce Web2Lead form relay.
Reference documentation for the SMTP output type,
hiddenfield type, and inline/file config sources.E2E test infrastructure:
test/e2e/testdata/env-vars.jsonandtest/e2e/testdata/config.yamlfor 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 withCONFIG_INLINEviatask start:api ENV_VARS=..., the docs/site build), moved out of the deployment page and the quickstart. Vendoring alternatives drops thevwalkthrough 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 byupload:config/validate:config, resolved against your project directory) now defaults toCONFIG_KEYinstead of the literalwebform.yaml. The two are distinct by design —CONFIGis the source path,CONFIG_KEYthe 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.
requriedinstead ofrequired) now fails to parse instead of silently ignoring it. Runwebform-relay config validateagainst your config after upgrading.Breaking: deploy-facing task names now require a
relay:prefix (task deploy:app→task relay:deploy:app,task aws:bootstrap→task relay:aws:bootstrap, etc. — the full list is every task documented in Getting Started), as a consequence of moving them intotaskfiles/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_BUCKETin.env.project, created bytask relay:aws:bootstrap-artifacts-bucket) forsam deploybuild artifacts instead ofsam 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 inROLLBACK_FAILEDif 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/credentialsor interactivesam deploy --guidedprompts.task relay:deploy:appis 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 atgithub.com/frob/webform-relay; the project is hosted on GitLab.v add/v updatecommands, theincludes:blocks, thecpcommands for the dotenv templates, and the vendor directory in every example are nowgitlab.com/frob/webform-relay— a consumer following the old instructions vendored tovendor/github.com/...and had to hand-edit every path. The release-tarball URL and theopenapi.yamllink use GitLab’s/-/archive/and/-/blob/forms. The Go module path (module github.com/frob/webform-relayingo.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:appvalidatesALLOWED_ORIGINSbefore 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 withInvalid format for origin. That rejection arrived only while CloudFormation was creating the HTTP API, minutes in, and left aROLLBACK_COMPLETEstack 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.jsonis ~3.4KB, so bothaws:bootstrap-userandaws:update-deploy-policyfailed withLimitExceeded: 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.shnow 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 aDeployPolicystatement (create/version/tag/delete, scoped toarn:aws:iam::*:policy/<deploy user>) andAttachUserPolicy/DetachUserPolicy/ListAttachedUserPolicieson the user — re-runtask aws:bootstrap-policyand update the policy attached to the bootstrap identity before re-running either task.task aws:bootstrap-usernever worked as written: inside itssh -cbody the wrappedaws iam put-user-policyinvocation had its--user-name/--policy-name/--policy-documentflags 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-useron an account where the deploy user already exists reported a bareEntityAlreadyExists; it now says so and points ataws:update-deploy-policyfor refreshing the policy. AnAccessDeniedoniam:CreateUser— the bootstrap identity’s policy having been rendered for a differentDEPLOY_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_NAMEcontainswebform-relay. ItsIAMRoles/IAMPassRolestatements were scoped toarn:aws:iam::*:role/*webform-relay*, but CloudFormation names the roles it generates after the stack — so any project deploying with, say,STACK_NAME=acme-formsgotUser ... is not authorized to perform: iam:CreateRolepartway throughdeploy:appordeploy:submissions, and the failed stack then stuck inROLLBACK_FAILEDbecause 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 configuredSTACK_NAMEalongside the default pattern;task aws:update-deploy-policyre-applies it after aSTACK_NAMEchange. 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_FLAGSinterpolated the access key and secret directly into eachdocker runcommand line, putting them in terminal scrollback, shell history,psoutput, 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$VARthe shell expands after Task has printed the command.An unset or blank
CONFIG_KEYno longer blanks the deployed stack’s config key:task deploy:apppassedConfigKey=through as an empty--parameter-overridesvalue, overridingtemplate.yaml’sDefault: webform.yamlwith an empty string rather than falling back to it.CONFIG_KEYis now defaulted in the taskfile (RELAY_CONFIG_KEY), the same wayCACHE_TTLand the throttle limits already were.task build:allon arm64 hosts (Apple Silicon): the dev tools image now derives the Docker apt repo architecture fromdpkg --print-architectureinstead of hard-codingamd64, and the Lambda container build sources itslinux/amd64bases under distinct local tags (via the newtools:lambda-basestarget) sosam buildproduces the x86_64 image without clobbering the nativegolang:1.23tag 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 (notsam 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