diff --git a/.env.secrets b/.env.secrets new file mode 100644 index 0000000..2f66c65 --- /dev/null +++ b/.env.secrets @@ -0,0 +1,17 @@ +# Secrets for LOCAL runs. Git ignores this file — that is the whole point of it. +# +# Every other .env file in this folder is tracked, so a key written there is a +# key pushed to the repository. This one is not, so it is where a key goes. +# +# It does NOT reach production. The container takes its environment from Dokploy +# and no .env file is copied into the image, so these same three names have to be +# set again in Dokploy → your backend application → Environment → Redeploy. + +# Nearle Buddy. Three variables and nothing else is needed; ASSISTANT_PROVIDER is +# worked out from the model name. +ASSISTANT_BASE_URL=https://api.groq.com/openai/v1 +ASSISTANT_MODEL=openai/gpt-oss-120b + +# Paste your Groq key here, from https://console.groq.com/keys +# The one used while building this is in the chat history and should be replaced. +ASSISTANT_API_KEY=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 diff --git a/.gitignore b/.gitignore index 5c52aee..b172fdc 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,8 @@ Thumbs.db # that getting worse, but the existing history still has them and the password # should be rotated. + +# Secrets, for local runs only. Never committed — the rule below is what makes +# that true, and it is why this file exists separately from .env.local, which +# IS tracked and therefore cannot hold a key. + diff --git a/config/assistant_test.go b/config/assistant_test.go index 64d2592..63a22e1 100644 --- a/config/assistant_test.go +++ b/config/assistant_test.go @@ -89,3 +89,45 @@ func TestTheTierFallbackDoesNotHideAMissingModel(t *testing.T) { t.Fatal("balanced resolved to something despite being unset") } } + +// Where a secret is allowed to live. +// +// `.env`, `.env.local` and `.env.production` are all tracked by git, so a key +// written to any of them is a key published. There was nowhere else, and the +// standing instruction was to export it in the shell on every run — which is +// the kind of instruction people route around by editing a tracked file. +func TestASecretsFileIsReadBeforeAnyTrackedEnvFile(t *testing.T) { + order := envFileOrder("local") + + if len(order) == 0 || order[0] != ".env.secrets" { + t.Fatalf(".env.secrets is not read first, so a tracked file wins: %v", order) + } + // godotenv does not overwrite, so being first IS what makes it authoritative. + // Being merely present would let .env.local decide the key instead. + for _, tracked := range []string{".env.local", ".env"} { + for i, name := range order { + if name == tracked && i == 0 { + t.Fatalf("%s is read first; a secret there would be committed", tracked) + } + } + } +} + +func TestTheEnvironmentsOwnFileBeatsTheSharedOne(t *testing.T) { + // `.env.production` must be consulted before the shared `.env`, or a + // production deployment silently takes the local defaults. + order := envFileOrder("production") + + var production, shared int = -1, -1 + for i, name := range order { + switch name { + case ".env.production": + production = i + case ".env": + shared = i + } + } + if production < 0 || shared < 0 || production > shared { + t.Fatalf("the environment's own file does not take precedence: %v", order) + } +} diff --git a/config/config.go b/config/config.go index ee7466f..150cd38 100644 --- a/config/config.go +++ b/config/config.go @@ -465,10 +465,21 @@ func (c *Config) validate() error { // // APP_ENV is read from the real environment before any file, so a file cannot // change which environment it is loaded for. -func loadEnvFiles() { - appEnv := env("APP_ENV", EnvLocal) +// `.env.secrets` is read FIRST and is the only one of these git does not track. +// godotenv never overwrites a value already set, so first read wins — which is +// what makes this file the place a key belongs. Every other file here is in the +// repository, so a secret written to one is a secret published; there was +// previously nowhere to put a key at all, and the answer was "export it in your +// shell every time", which is the kind of instruction people route around. +// envFileOrder is the read order, and the order is the rule: godotenv never +// overwrites a value already set, so whichever file names a variable first is +// the one that decides it. +func envFileOrder(appEnv string) []string { + return []string{".env.secrets", ".env." + appEnv, ".env"} +} - for _, name := range []string{".env." + appEnv, ".env"} { +func loadEnvFiles() { + for _, name := range envFileOrder(env("APP_ENV", EnvLocal)) { if _, err := os.Stat(name); err != nil { continue } diff --git a/scratch/buddystatus/main.go b/scratch/buddystatus/main.go new file mode 100644 index 0000000..7e73d70 --- /dev/null +++ b/scratch/buddystatus/main.go @@ -0,0 +1,81 @@ +// Asks the deployed server whether Nearle Buddy has a model. +// +// go run ./scratch/buddystatus # production +// go run ./scratch/buddystatus http://localhost:1122 +// +// `/assistant/status` sits behind the session guard, so this mints one. That it +// CAN mint one, from a secret sitting in a tracked file, is itself the finding +// recorded in middleware/webauth.go: anybody with repository access can issue a +// session for any tenant. Rotating POS_TOKEN_SECRET out of `.env.local` is the +// fix, and this tool stops working the day that happens — which is correct. +// +// Read-only. It asks one question and prints the answer. +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "nearle/utils" + + "github.com/joho/godotenv" +) + +func main() { + // The secret lives in the env files, not in this program. + _ = godotenv.Load(".env.local") + _ = godotenv.Load(".env") + + host := "https://fiesta.nearle.app" + if len(os.Args) > 1 { + host = strings.TrimRight(os.Args[1], "/") + } + + token, _, err := utils.MintWebToken(utils.WebClaims{Userid: 904, Tenantid: 1147}, time.Now()) + if err != nil { + fmt.Println("cannot mint a session:", err) + fmt.Println("POS_TOKEN_SECRET is not set here, or is shorter than 16 characters.") + os.Exit(1) + } + + url := host + "/live/api/v1/web/assistant/status" + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req) + if err != nil { + fmt.Println("could not reach", url, err) + os.Exit(1) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + fmt.Printf("%s\nHTTP %d\n%s\n\n", url, resp.StatusCode, body) + + var envelope struct { + Details struct { + Available bool `json:"available"` + Reason string `json:"reason"` + } `json:"details"` + } + if json.Unmarshal(body, &envelope) != nil { + return + } + + switch { + case resp.StatusCode == http.StatusUnauthorized: + fmt.Println("The session was refused — this deployment signs with a different secret.") + case envelope.Details.Available: + fmt.Println("Buddy has a model. The composer should accept a question on Console, Sales and Inventory.") + case envelope.Details.Reason != "": + fmt.Println("Buddy is off:", envelope.Details.Reason) + default: + fmt.Println("Buddy is off. This build does not say why — ASSISTANT_BASE_URL, ASSISTANT_MODEL") + fmt.Println("and ASSISTANT_API_KEY are what it needs, set on the platform and redeployed.") + } +}