From b680d175eeb2fa88e97359267382b73efc9072bf Mon Sep 17 00:00:00 2001 From: JJ Eaton <58495321+jayleaton@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:21:15 +0700 Subject: [PATCH] fix(deploy): plan and apply refuse an unset or example email.from Every service runs with APP_ENV=production, and the apps refuse to boot with an unset FROM_EMAIL or one on a reserved example/test domain (#103). deploy/ defaulted email.from to noreply@example.com, so a fresh make railway-setup on defaults would deploy apps that never boot. - platform: RealSender, the apps' exact rule (example.com/.net/.org, example, test, invalid, localhost; "Name " allowed), and SenderProblems per environment (email.from or per_environment..from_email; manual is not checked). email.from now defaults to unset. - railway-setup: plan, setup and apply refuse before touching anything, with a next step; apply on a terminal without --yes asks for the sender and writes it into platform.yaml. New --email-from / PLATFORM_EMAIL_FROM (platform.Edit now sets dotted keys). doctor fails the email.from check. - docs/deploy.md and AGENTS.md: a real sender is required. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- deploy/cmd/railway-setup/doctor.go | 5 +++ deploy/cmd/railway-setup/main.go | 39 ++++++++++++++++++++++ deploy/cmd/railway-setup/overrides.go | 1 + deploy/platform.yaml | 6 +++- deploy/platform/config.go | 47 +++++++++++++++++++++++++-- deploy/platform/edit.go | 29 ++++++++++++++--- docs/deploy.md | 2 ++ 8 files changed, 123 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bb2c4a4..28e1f0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,7 +169,7 @@ Secrets never reach templates, SSE, JSON or logs: Only when the developer asks: it creates billed resources in their Railway account. 1. **Preflight.** `bin/railway-setup` is built by any `make railway-*` target. Run `make railway-doctor ARGS=--json` and fix every check with `"status": "fail"`; each check carries a `fix`. Typical fixes: `railway login` (the developer's browser), `--workspace` when they belong to several, and `railway ssh keys add` when there is more than one environment. -2. **Setup.** `make railway-setup ARGS="--yes --json --environments staging,production"`. Add `--admin true`, `--region`, `--project` or `--workspace` as asked. Every flag has a `PLATFORM_*` env equivalent (`PLATFORM_YES=1`, `PLATFORM_JSON=1`, `PLATFORM_ENVIRONMENTS`, ...). The flags are written into `deploy/platform.yaml`: commit that file. Without `--yes`, and with no terminal to ask on, creating a project stops with exit 3. +2. **Setup.** `make railway-setup ARGS="--yes --json --environments staging,production --email-from noreply@"`. A real sender is required: plan, setup and apply refuse an unset or example `email.from` (production refuses to boot with one), and `nextSteps` says so. Ask the developer for the sender on their verified email domain; never invent one. Add `--admin true`, `--region`, `--project` or `--workspace` as asked. Every flag has a `PLATFORM_*` env equivalent (`PLATFORM_YES=1`, `PLATFORM_JSON=1`, `PLATFORM_ENVIRONMENTS`, ...). The flags are written into `deploy/platform.yaml`: commit that file. Without `--yes`, and with no terminal to ask on, creating a project stops with exit 3. 3. **Read the summary.** stdout is one JSON object: `ok`, `project.{id,url}`, `source` (`github` or `upload`), each environment's `branch` (with `github`: production `main`, staging and qa `dev` unless `branches` says otherwise) and services with `url`, `deployment` and `health`, and `nextSteps`. Logs go to stderr. Relay `nextSteps` to the developer verbatim. They are things only a person can do, such as installing the GitHub app, Doppler tokens or a real `RESEND_API_KEY`. Never invent or fill in secrets yourself. 4. **Doppler** (`secrets.provider: doppler`): when the developer has run `doppler login`, setup also moves the app secrets into Doppler ([docs/secrets.md](docs/secrets.md#secrets-and-doppler)); otherwise `nextSteps` says so, and `make doppler-setup ARGS="--json"` does it later. Its output names configs, secret names and fingerprints only. Keep it that way: never print, log or paste a token or secret value. 5. **Hand-set values.** Never set a Railway variable by hand without listing it in `unmanaged_variables` or as `manual` in `per_environment` ([docs/deploy.md](docs/deploy.md#hand-set-variables)): the next apply deletes it. `make deploy-vars` must pass. diff --git a/deploy/cmd/railway-setup/doctor.go b/deploy/cmd/railway-setup/doctor.go index 3a2d46e..a0f99cf 100644 --- a/deploy/cmd/railway-setup/doctor.go +++ b/deploy/cmd/railway-setup/doctor.go @@ -40,6 +40,11 @@ func (setup *setup) doctor(ctx context.Context) error { return withCode(exitPreflight, "preflight failed") } setup.report(check{Name: "railway cli", Status: "ok", Detail: strings.TrimSpace(firstLine(string(who)))}) + if problems := setup.cfg.SenderProblems(); len(problems) > 0 { + fail(check{Name: "email.from", Detail: strings.Join(problems, "; ") + ": production refuses to boot without a real sender", Fix: senderFix}) + } else { + setup.report(check{Name: "email.from", Status: "ok", Detail: setup.cfg.Email.From}) + } if ws, err := setup.workspaceID(ctx); err != nil { fail(check{Name: "workspace", Detail: err.Error(), Fix: "set workspace in deploy/platform.yaml or pass --workspace / PLATFORM_WORKSPACE"}) diff --git a/deploy/cmd/railway-setup/main.go b/deploy/cmd/railway-setup/main.go index 49056d3..ab76971 100644 --- a/deploy/cmd/railway-setup/main.go +++ b/deploy/cmd/railway-setup/main.go @@ -182,6 +182,9 @@ func (setup *setup) main(cmd, config, iac, only string, wait time.Duration, ovs func (setup *setup) reconcile(ctx context.Context, wait time.Duration) error { setup.say("platform.yaml: project %q, environments %v, admin %v, previews %v, secrets %s, repo %q", setup.cfg.Project, []string(setup.cfg.Environments), setup.cfg.Admin, setup.cfg.Previews, setup.cfg.Secrets.Provider, setup.cfg.Repo) + if err := setup.requireSender(); err != nil { + return err + } if err := setup.ensureProject(ctx); err != nil { return err } @@ -368,6 +371,42 @@ func (setup *setup) dopplerPlaceholders(ctx context.Context) []string { return out } +// senderFix is how to give the apps a real sender. +const senderFix = "set email.from in deploy/platform.yaml to a sender on your verified email domain " + + "(or pass --email-from / PLATFORM_EMAIL_FROM; per environment: per_environment..from_email)" + +// requireSender refuses plan and apply before anything changes when an +// environment's apps would get an unset or example FROM_EMAIL: they run +// with APP_ENV=production, which refuses to boot with one, so the deploy +// would end in a crash loop. apply on a terminal without --yes asks for one +// and writes it into platform.yaml. +func (setup *setup) requireSender() error { + problems := setup.cfg.SenderProblems() + if len(problems) == 0 { + return nil + } + if setup.apply && !setup.yes && stty(context.Background(), "-g") == nil { + fmt.Fprint(os.Stderr, "Sender address for auth emails, on your verified email domain (e.g. noreply@yourdomain.com): ") + line, _ := bufio.NewReader(os.Stdin).ReadString('\n') + sender := strings.TrimSpace(line) + if platform.RealSender(sender) { + changed, err := persist(setup.cfgPath, []*override{{key: "email.from", value: &sender}}) + if err != nil { + return err + } + setup.say("platform.yaml: set %s (commit it)", strings.Join(changed, ", ")) + if setup.cfg, err = platform.LoadResolved(setup.cfgPath); err != nil { + return err + } + if problems = setup.cfg.SenderProblems(); len(problems) == 0 { + return nil + } + } + } + setup.next("Set a real sender for auth emails: %s", senderFix) + return fmt.Errorf("refusing to deploy apps that would not boot: production needs a real FROM_EMAIL (%s): %s", strings.Join(problems, "; "), senderFix) +} + // confirm asks on a terminal; without one it needs --yes. func (setup *setup) confirm(question string) error { if setup.yes { diff --git a/deploy/cmd/railway-setup/overrides.go b/deploy/cmd/railway-setup/overrides.go index e641c7d..ef4d030 100644 --- a/deploy/cmd/railway-setup/overrides.go +++ b/deploy/cmd/railway-setup/overrides.go @@ -25,6 +25,7 @@ func overrides() []*override { {key: "admin", flagName: "admin", env: "PLATFORM_ADMIN", usage: "true|false: the admin dashboard and its own Postgres"}, {key: "region", flagName: "region", env: "PLATFORM_REGION", usage: "Railway region for every service (\"\": Railway's default)"}, {key: "repo", flagName: "repo", env: "PLATFORM_REPO", usage: "GitHub owner/name, auto (origin remote) or \"\" (upload only)"}, + {key: "email.from", flagName: "email-from", env: "PLATFORM_EMAIL_FROM", usage: "sender of auth emails, on your verified email domain (required: production refuses an example one)"}, } } diff --git a/deploy/platform.yaml b/deploy/platform.yaml index db92c54..8d0b03f 100644 --- a/deploy/platform.yaml +++ b/deploy/platform.yaml @@ -60,8 +60,12 @@ secrets: # config (prd_admin, ...) the web token cannot read. project: go-fast-stack +# Required: the sender of every auth email (FROM_EMAIL), on a domain your +# mail provider (Resend) has verified. The apps run with APP_ENV=production, +# which refuses to boot without one or with an example address, so plan and +# apply refuse first. Or pass --email-from / PLATFORM_EMAIL_FROM. email: - from: noreply@example.com + from: "" # What one environment's apps see as their origin and sender. public_url # (web PUBLIC_URL) and admin_public_url (ADMIN_PUBLIC_URL): railway for the diff --git a/deploy/platform/config.go b/deploy/platform/config.go index 5f2d9b1..f2447e0 100644 --- a/deploy/platform/config.go +++ b/deploy/platform/config.go @@ -167,6 +167,49 @@ func (config Config) Settings(env string) EnvSettings { return settings } +// reservedSenderDomains are the example and test domains (RFC 2606, RFC +// 6761) no mail provider sends from. The apps refuse a production +// FROM_EMAIL on them at boot (apps/*/internal/config realSender): keep the +// two lists the same. +var reservedSenderDomains = []string{"example.com", "example.net", "example.org", "example", "test", "invalid", "localhost"} + +// RealSender reports whether address ("a@b.c" or "Name ") is an email +// address off the reserved example and test domains: the rule the apps +// apply to FROM_EMAIL in production. +func RealSender(address string) bool { + address = strings.TrimSuffix(strings.TrimSpace(address), ">") + at := strings.LastIndex(address, "@") + if at <= 0 || at == len(address)-1 { + return false + } + domain := strings.TrimSuffix(strings.ToLower(address[at+1:]), ".") + for _, reserved := range reservedSenderDomains { + if domain == reserved || strings.HasSuffix(domain, "."+reserved) { + return false + } + } + return true +} + +// SenderProblems lists the environments whose apps would get an unset or +// example FROM_EMAIL (email.from, or per_environment..from_email): +// every service runs with APP_ENV=production, which refuses to boot with +// one. A manual value is set by hand in Railway and is not checked here. +func (config Config) SenderProblems() []string { + var out []string + for _, env := range config.Environments { + from := config.Settings(env).FromEmail + switch { + case from == ManualValue: + case from == "": + out = append(out, env+": email.from is not set") + case !RealSender(from): + out = append(out, fmt.Sprintf("%s: %q is an example or test address", env, from)) + } + } + return out +} + // HandSet lists env's variables IaC leaves alone because platform.yaml // marks them manual, as "service/NAME". Plan and apply print them. func (config Config) HandSet(env string) []string { @@ -288,7 +331,7 @@ var ( Branch: "main", Environments: Environments{"production"}, Secrets: Secrets{Provider: ProviderDoppler, Project: DefaultDopplerProject}, - Email: Email{From: "noreply@example.com"}, + Email: Email{}, // From is required: a sender on your verified domain (SenderProblems) Backups: Backups{Enabled: true, Schedule: "0 3 * * *", KeepDaily: defaultKeepDaily, KeepWeekly: defaultKeepWeekly, Admin: true, Storage: StorageRailway}, Resources: Resources{ Web: Size{CPU: 1, MemoryMB: defaultWebMemoryMB, Replicas: 1}, @@ -385,7 +428,7 @@ func (config Config) Validate() error { default: add("secrets.provider must be %s or %s, got %q", ProviderDoppler, ProviderEnv, config.Secrets.Provider) } - if !strings.Contains(config.Email.From, "@") { + if config.Email.From != "" && !strings.Contains(config.Email.From, "@") { add("email.from must be an email address, got %q", config.Email.From) } for env, settings := range config.PerEnvironment { diff --git a/deploy/platform/edit.go b/deploy/platform/edit.go index 3d7ab0b..3c25abd 100644 --- a/deploy/platform/edit.go +++ b/deploy/platform/edit.go @@ -30,10 +30,18 @@ func Edit(raw []byte, keys []Key) ([]byte, []string, error) { var changed []string for _, entry := range keys { text := render(entry) + // A dotted name ("email.from") is a key of a top-level mapping. + mapping, name := root, entry.Name + if parent, child, nested := strings.Cut(entry.Name, "."); nested { + if mapping = childMapping(root, parent); mapping == nil { + return nil, nil, fmt.Errorf("platform.yaml: add %s:\n %s: %s to set it automatically", parent, child, text) + } + name = child + } found := false - for index := 0; index+1 < len(root.Content); index += 2 { - key, val := root.Content[index], root.Content[index+1] - if key.Value != entry.Name { + for index := 0; index+1 < len(mapping.Content); index += 2 { + key, val := mapping.Content[index], mapping.Content[index+1] + if key.Value != name { continue } found = true @@ -41,7 +49,7 @@ func Edit(raw []byte, keys []Key) ([]byte, []string, error) { break } if val.Line != key.Line || val.Line > len(lines) { - return nil, nil, fmt.Errorf("platform.yaml: put %s on one line (e.g. %s: %s) to set it automatically", entry.Name, entry.Name, text) + return nil, nil, fmt.Errorf("platform.yaml: put %s on one line (e.g. %s: %s) to set it automatically", entry.Name, name, text) } line := lines[val.Line-1] start := val.Column - 1 @@ -55,6 +63,9 @@ func Edit(raw []byte, keys []Key) ([]byte, []string, error) { break } if !found { + if mapping != root { + return nil, nil, fmt.Errorf("platform.yaml: add %s: %s under %s to set it automatically", name, text, strings.TrimSuffix(entry.Name, "."+name)) + } if count := len(lines); count > 0 && !strings.HasSuffix(lines[count-1], "\n") { lines[count-1] += "\n" } @@ -72,6 +83,16 @@ func Edit(raw []byte, keys []Key) ([]byte, []string, error) { return out, changed, nil } +// childMapping is the mapping under a top-level key, or nil. +func childMapping(root *yaml.Node, name string) *yaml.Node { + for index := 0; index+1 < len(root.Content); index += 2 { + if root.Content[index].Value == name && root.Content[index+1].Kind == yaml.MappingNode { + return root.Content[index+1] + } + } + return nil +} + // render is a value as YAML text: booleans bare, strings plain when that // reads back as the same string, else double-quoted. func render(key Key) string { diff --git a/docs/deploy.md b/docs/deploy.md index a0f3f1e..e96a414 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -10,6 +10,8 @@ make railway-doctor # login, workspace, SSH key, GitHub access, Doppler: print make railway-setup # asks once before creating the project; ends with every /healthz checked ``` +**A real sender is required.** Every service runs with `APP_ENV=production`, which refuses to boot when `FROM_EMAIL` is unset or on an example or test domain (`example.com`, `example.net`, `example.org`, `.example`, `.test`, `.invalid`, `localhost`). So `make railway-plan`, `make railway-setup` and `make railway-apply` refuse first and touch nothing until `email.from` in `deploy/platform.yaml` is a sender on a domain your mail provider has verified. Per environment, `per_environment..from_email` overrides it (`manual`: you set `FROM_EMAIL` in Railway yourself). Setup on a terminal asks for the sender and writes it into `platform.yaml`. Otherwise pass `--email-from noreply@yourdomain.com` or `PLATFORM_EMAIL_FROM`, which are written into the file the same way. `make railway-doctor` reports it as a failed check. + The defaults come from the checkout: the project is named after your repo, and production is the only environment. Choose on the first run: `make railway-setup ARGS="--environments staging,production --admin true --workspace acme"`. These flags (and `--project`, `--region`, `--repo`; or `PLATFORM_*` env vars) are written into `platform.yaml`, so commit it afterwards. `make railway-plan` shows what would change without touching anything (exit 2 when something would). `make railway-verify` checks `/healthz` on every public URL. `make railway-rotate-db` gives every Postgres a new superuser password (see below). Agents: see AGENTS.md "Deploy to Railway (for agents)" for `--yes`, `--json` and exit codes.