// Package config reads the service's settings from the environment. package config import ( "errors" "fmt" "os" "strconv" "strings" "time" "textmachine/platform/internal/money" ) // Config is the whole configuration surface. Environment only: a control plane is deployed, not // hand-run, and a settings file is one more thing to keep in sync with the deployment. type Config struct { Addr string // DSN is the Postgres connection string. Empty is allowed and means "start unready": the // service answers liveness and reports on /readyz why it cannot serve. // // ⚠ It carries a password. It is read from the environment, never logged, and never echoed // into an error message. DSN string // TrustedOrigins are origins besides our own that may make unsafe requests. TrustedOrigins []string // SessionIdleTTL is how long a session survives without use; SessionMaxAge is the ceiling no // amount of use can extend. Both are policy, and the policy — the two values, the concurrent // session rule and what our session does when the provider's ends — is written down in // STACK_DECISIONS §13, because ASVS 5.0 7.1.1 asks for the document, not the number. SessionIdleTTL time.Duration SessionMaxAge time.Duration // Migrate applies pending migrations at boot. Off by default: a rollout should migrate once, // deliberately, not once per replica. Migrate bool // InsecureCookies serves the session over plain HTTP under a different cookie name. A DEV // switch: __Host- requires Secure, so localhost cannot use the production name at all (PD-8). InsecureCookies bool // OIDC is the sign-in provider. Empty issuer means no login surface is mounted — the service // still runs, which is what keeps a bare `go run` useful. // OIDCProvider is OUR name for the issuer and the first half of the identity key. It is // configured next to the issuer because the two must move together: pointing the issuer at a // different IdP while keeping the name would file that IdP's subjects under the old provider — // the silent account-linking the identity model exists to prevent. OIDCProvider string OIDCIssuer string OIDCClientID string OIDCClientSecret string OIDCRedirectURL string // AfterLogin is where a completed sign-in lands. AfterLogin string // SignupGrantMicroUSD is the credit a new account is created with (the free tier, owner 05.08: // default five dollars, settable per account by granting a different amount). SignupGrantMicroUSD int64 } // Load reads the environment. func Load() (Config, error) { c := Config{ Addr: env("TM_PLATFORM_ADDR", "127.0.0.1:8080"), DSN: "", // read below: it may come from a file // 14 days idle, 30 days absolute. The absolute one is the NIST SP 800-63B-4 AAL1 figure // ("SHOULD be no more than 30 days"), not a preference: it was 90 days, and a deviation from // a SHOULD needs a reason that survives inspection, which that one did not (PD-58, §13). SessionIdleTTL: 14 * 24 * time.Hour, SessionMaxAge: 30 * 24 * time.Hour, OIDCProvider: env("TM_PLATFORM_OIDC_PROVIDER", "google"), OIDCIssuer: os.Getenv("TM_PLATFORM_OIDC_ISSUER"), OIDCClientID: os.Getenv("TM_PLATFORM_OIDC_CLIENT_ID"), OIDCClientSecret: "", // read below: it may come from a file OIDCRedirectURL: os.Getenv("TM_PLATFORM_OIDC_REDIRECT_URL"), AfterLogin: env("TM_PLATFORM_AFTER_LOGIN", "/"), SignupGrantMicroUSD: 5 * 1_000_000, } if raw := os.Getenv("TM_PLATFORM_TRUSTED_ORIGINS"); raw != "" { for _, o := range strings.Split(raw, ",") { if o = strings.TrimSpace(o); o != "" { c.TrustedOrigins = append(c.TrustedOrigins, o) } } } var err error if c.Migrate, err = boolean("TM_PLATFORM_MIGRATE"); err != nil { return Config{}, err } if c.InsecureCookies, err = boolean("TM_PLATFORM_INSECURE_COOKIES"); err != nil { return Config{}, err } if c.DSN, err = secret("TM_PLATFORM_DSN"); err != nil { return Config{}, err } if c.OIDCClientSecret, err = secret("TM_PLATFORM_OIDC_CLIENT_SECRET"); err != nil { return Config{}, err } if c.SessionIdleTTL, err = duration("TM_PLATFORM_SESSION_IDLE", c.SessionIdleTTL); err != nil { return Config{}, err } if c.SessionMaxAge, err = duration("TM_PLATFORM_SESSION_MAX_AGE", c.SessionMaxAge); err != nil { return Config{}, err } if c.SessionIdleTTL > c.SessionMaxAge { return Config{}, fmt.Errorf("config: session idle TTL %s exceeds max age %s", c.SessionIdleTTL, c.SessionMaxAge) } if raw := os.Getenv("TM_PLATFORM_SIGNUP_GRANT_USD"); raw != "" { v, err := money.ParseUSD(raw) if err != nil { return Config{}, fmt.Errorf("config: TM_PLATFORM_SIGNUP_GRANT_USD: %w", err) } if v < 0 { return Config{}, errors.New("config: TM_PLATFORM_SIGNUP_GRANT_USD cannot be negative") } c.SignupGrantMicroUSD = int64(v) } // Half a login configuration is worse than none: the surface would mount and fail at the first // click instead of at boot, where an operator is watching. oidc := []string{c.OIDCIssuer, c.OIDCClientID, c.OIDCClientSecret, c.OIDCRedirectURL} set := 0 for _, v := range oidc { if v != "" { set++ } } if set != 0 && set != len(oidc) { return Config{}, errors.New("config: OIDC needs all of TM_PLATFORM_OIDC_ISSUER, _CLIENT_ID, _CLIENT_SECRET, _REDIRECT_URL, or none") } return c, nil } // LoginEnabled reports whether a sign-in provider is configured. func (c Config) LoginEnabled() bool { return c.OIDCIssuer != "" } // Secret is the shared way to read a credential: from KEY, or preferably from the file named by // KEY_FILE. Exported so the admin CLI reads the DSN the same way the daemon does — an operator who // followed the deploy notes has it in a file, not in the environment. func Secret(key string) (string, error) { return secret(key) } // secret reads a value from KEY, or — preferably — from the file named by KEY_FILE. A secret in a // file does not show up in /proc//environ, is not inherited by child processes, and is exactly // what systemd's LoadCredential= hands over (deploy/tmplatformd.service). func secret(key string) (string, error) { if path := os.Getenv(key + "_FILE"); path != "" { b, err := os.ReadFile(path) if err != nil { // The path, never the content: an unreadable secret file is an operator's problem and // the error goes to the log. return "", fmt.Errorf("config: %s_FILE: %w", key, err) } return strings.TrimSpace(string(b)), nil } return os.Getenv(key), nil } // boolean reads a flag. strconv.ParseBool rather than a comparison with "1": an operator who wrote // `true` deserves an error or the truth, not a silent no. func boolean(key string) (bool, error) { raw := os.Getenv(key) if raw == "" { return false, nil } v, err := strconv.ParseBool(raw) if err != nil { return false, fmt.Errorf("config: %s: %q is not a boolean", key, raw) } return v, nil } func env(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } func duration(key string, def time.Duration) (time.Duration, error) { raw := os.Getenv(key) if raw == "" { return def, nil } d, err := time.ParseDuration(raw) if err != nil { return 0, fmt.Errorf("config: %s: %w", key, err) } if d <= 0 { return 0, fmt.Errorf("config: %s must be positive", key) } return d, nil }