| 1 | package memoria |
| 2 | |
| 3 | import ( |
| 4 | "encoding/base64" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | |
| 8 | "amadan.net/oficina/home/connect" |
| 9 | |
| 10 | "amadan.net/oficina/memoria/internal/memoria/seal" |
| 11 | ) |
| 12 | |
| 13 | // Config is everything memoria needs from platform config. |
| 14 | // |
| 15 | // This is a type with a constructor, rather than a run of os.Getenv calls |
| 16 | // in main, for one reason: the refusals below are a safety property and a |
| 17 | // safety property needs a test. An earlier version of this validation |
| 18 | // lived inline in main.go and was "checked" by grepping the source for |
| 19 | // os.Exit near each variable name — which passed even when the refusal |
| 20 | // was deleted, because a neighbouring error path supplied the os.Exit the |
| 21 | // grep was looking for. A guard that cannot fail is not a guard. |
| 22 | type Config struct { |
| 23 | Origin string |
| 24 | InstanceKey string |
| 25 | Owners []string |
| 26 | Sealer *seal.Sealer |
| 27 | Home *connect.DecodedCredentials |
| 28 | // AllowLoggedMail permits booting with no SMTP, which makes |
| 29 | // rastrillo substitute mail.Logged — and mail.Logged writes the |
| 30 | // magic link, a live credential, into the process log at Warn. |
| 31 | // `carlos logs` is platform-held storage we cannot grep, so that is |
| 32 | // a credential we cannot retract. Opt in explicitly for local dev. |
| 33 | AllowLoggedMail bool |
| 34 | |
| 35 | // AI is the team owner's opt-in to the model layer (decision 4). With |
| 36 | // it off, nothing is ever sent to a provider and §8.9 does nothing. |
| 37 | AI bool |
| 38 | // TensorXKey is the provider credential. Required when AI is on, |
| 39 | // ignored when it is off. Never logged, never in an error. |
| 40 | TensorXKey string |
| 41 | } |
| 42 | |
| 43 | // ErrMissing is returned when required platform config is absent. |
| 44 | var ErrMissing = errors.New("memoria: required configuration is missing") |
| 45 | |
| 46 | // LoadConfig reads and validates configuration. getenv is a parameter so |
| 47 | // a test can supply an environment without mutating the process's. |
| 48 | // |
| 49 | // Every failure here is fatal by design. There is deliberately no |
| 50 | // generated-fallback DEK: a fallback would seal this boot's corpus under |
| 51 | // a key the next boot cannot derive, losing the data quietly instead of |
| 52 | // refusing loudly. And there is no default owner list, because an empty |
| 53 | // admission gate on a public hostname admits anyone who can receive mail. |
| 54 | func LoadConfig(getenv func(string) string) (Config, error) { |
| 55 | var c Config |
| 56 | |
| 57 | dekRaw := getenv("MEMORIA_DEK") |
| 58 | if dekRaw == "" { |
| 59 | return c, fmt.Errorf("%w: MEMORIA_DEK", ErrMissing) |
| 60 | } |
| 61 | dek, err := base64.StdEncoding.DecodeString(dekRaw) |
| 62 | if err != nil { |
| 63 | return c, fmt.Errorf("memoria: MEMORIA_DEK is not valid base64: %w", err) |
| 64 | } |
| 65 | sealer, err := seal.New(dek) |
| 66 | if err != nil { |
| 67 | return c, fmt.Errorf("memoria: MEMORIA_DEK rejected: %w", err) |
| 68 | } |
| 69 | c.Sealer = sealer |
| 70 | |
| 71 | if c.InstanceKey = getenv("MEMORIA_INSTANCE_KEY"); c.InstanceKey == "" { |
| 72 | return c, fmt.Errorf("%w: MEMORIA_INSTANCE_KEY", ErrMissing) |
| 73 | } |
| 74 | |
| 75 | if c.Owners = ParseOwners(getenv("MEMORIA_OWNERS")); len(c.Owners) == 0 { |
| 76 | return c, fmt.Errorf("%w: MEMORIA_OWNERS (no admission gate)", ErrMissing) |
| 77 | } |
| 78 | |
| 79 | // Refuse to log credentials by default. |
| 80 | // |
| 81 | // The prefix is CARLOS, not MEMORIA: rastrillo's mail.FromEnv takes |
| 82 | // the prefix as an argument and the suite passes "CARLOS", so the |
| 83 | // platform config is CARLOS_SMTP_*. An earlier version of this check |
| 84 | // read MEMORIA_SMTP_HOST, which no deployment sets — so it would have |
| 85 | // refused to boot on a correctly configured instance, and the obvious |
| 86 | // fix under deadline would have been to delete the check. |
| 87 | // |
| 88 | // Both HOST and FROM, because mail.FromEnv falls back to the logging |
| 89 | // sender unless it has both. Checking only HOST would let a |
| 90 | // half-configured instance log links while this function reported |
| 91 | // everything fine. |
| 92 | c.AllowLoggedMail = getenv("MEMORIA_ALLOW_LOGGED_MAIL") == "1" |
| 93 | if !c.AllowLoggedMail { |
| 94 | if getenv("CARLOS_SMTP_HOST") == "" || getenv("CARLOS_SMTP_FROM") == "" { |
| 95 | return c, fmt.Errorf("%w: CARLOS_SMTP_HOST and CARLOS_SMTP_FROM (or MEMORIA_ALLOW_LOGGED_MAIL=1 to log sign-in links instead, which puts a live credential in a log nobody can delete)", ErrMissing) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // The model layer: off unless the owner says "on", in exactly those |
| 100 | // words (decision 4 — opt-in per team). |
| 101 | // |
| 102 | // **Only "on" and "" are accepted.** A typo — "yes", "true", "On" — |
| 103 | // is a refusal, not a silent off: an owner who meant to switch AI on |
| 104 | // and got nothing would be told nothing, and an owner who meant to |
| 105 | // switch it OFF and wrote "false" must not be read as meaning on. |
| 106 | // Refusing both is the only reading that cannot be wrong. |
| 107 | // |
| 108 | // And a key with AI off does NOT turn AI on. Presence of a |
| 109 | // credential is not consent; the switch is. |
| 110 | switch ai := getenv("MEMORIA_AI"); ai { |
| 111 | case "": |
| 112 | case "on": |
| 113 | c.AI = true |
| 114 | // MEMORIA_TENSORX_KEY, because that is the name the credential was |
| 115 | // already given on the platform (set by hand for the 2026-09-05 |
| 116 | // live-key checks). This code first read MEMORIA_TENSORX_API_KEY, |
| 117 | // which would have meant a second copy of the key under a new name — |
| 118 | // and a platform secret cannot be read back to make one. |
| 119 | if c.TensorXKey = getenv("MEMORIA_TENSORX_KEY"); c.TensorXKey == "" { |
| 120 | return c, fmt.Errorf("%w: MEMORIA_TENSORX_KEY (MEMORIA_AI=on names a provider but gives no credential for it)", ErrMissing) |
| 121 | } |
| 122 | default: |
| 123 | return c, fmt.Errorf("memoria: MEMORIA_AI must be \"on\" or unset; refusing to guess what %q means", ai) |
| 124 | } |
| 125 | |
| 126 | // Origin is the only one with a default, and only because the |
| 127 | // default is a loopback address that cannot be reached from |
| 128 | // anywhere else. It decides the CSRF yardstick and the cookie |
| 129 | // attributes, so a silent production default would be a real bug — |
| 130 | // hence the caller logs a warning when it is used. |
| 131 | if c.Origin = getenv("MEMORIA_ORIGIN"); c.Origin == "" { |
| 132 | c.Origin = "http://localhost:8080" |
| 133 | } |
| 134 | home := connect.Credentials{Issuer: getenv("MEMORIA_HOME_ISSUER"), ConsumerID: getenv("MEMORIA_HOME_CONSUMER_ID"), KeysBase64: getenv("MEMORIA_HOME_KEYS_BASE64"), DEKBase64: getenv("MEMORIA_HOME_DEK_BASE64")} |
| 135 | if home.Issuer != "" || home.ConsumerID != "" || home.KeysBase64 != "" || home.DEKBase64 != "" { |
| 136 | // The hub directory "All apps" links to: unprefixed, the name and |
| 137 | // value Home itself reads, as connect.CredentialsFromEnv does. It |
| 138 | // is optional and enables nothing, so it does not count toward |
| 139 | // "Home is configured"; DecodeCredentials validates it. |
| 140 | home.Directory = getenv("HOME_DIRECTORY_ORIGIN") |
| 141 | c.Home, err = connect.DecodeCredentials(&home, c.Origin) |
| 142 | if err != nil { |
| 143 | return c, err |
| 144 | } |
| 145 | } |
| 146 | return c, nil |
| 147 | } |
| 148 | |
| 149 | // UsedDefaultOrigin reports whether the origin was defaulted, so main can |
| 150 | // say so out loud. |
| 151 | func (c Config) UsedDefaultOrigin() bool { return c.Origin == "http://localhost:8080" } |
| 152 | |