From c02d6870600a364725368ccc16ce1af1b6b1db14 Mon Sep 17 00:00:00 2001 From: Ola Adebayo Date: Wed, 30 Sep 2026 19:13:48 +0100 Subject: [PATCH 1/7] chore(gateway): clear pre-existing gofmt drift and errcheck finding `gofmt -l` listed three files (trailing blank lines, comment alignment) and `golangci-lint run` reported one errcheck: the unchecked deferred `f.Close()` in envfile.go. Both predate this branch. Fixed here so `make fmt` is a no-op and `make lint` reports 0 issues. No behaviour change. --- gateway/internal/config/envfile.go | 2 +- gateway/internal/crypto/session_key.go | 1 - gateway/internal/crypto/session_key_test.go | 1 - gateway/internal/inject/inject_perf_test.go | 8 ++++---- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/gateway/internal/config/envfile.go b/gateway/internal/config/envfile.go index fab4393..8712cfb 100644 --- a/gateway/internal/config/envfile.go +++ b/gateway/internal/config/envfile.go @@ -19,7 +19,7 @@ func ParseEnvFile(path string) (map[string]string, error) { if err != nil { return nil, fmt.Errorf("opening env file %q: %w", path, err) } - defer f.Close() + defer func() { _ = f.Close() }() vars := make(map[string]string) scanner := bufio.NewScanner(f) diff --git a/gateway/internal/crypto/session_key.go b/gateway/internal/crypto/session_key.go index 7ecb188..200f81e 100644 --- a/gateway/internal/crypto/session_key.go +++ b/gateway/internal/crypto/session_key.go @@ -258,4 +258,3 @@ func (h *SessionKeyHandler) CORSPreflight(w http.ResponseWriter, r *http.Request } w.WriteHeader(http.StatusNoContent) } - diff --git a/gateway/internal/crypto/session_key_test.go b/gateway/internal/crypto/session_key_test.go index c9ff969..5a98dcd 100644 --- a/gateway/internal/crypto/session_key_test.go +++ b/gateway/internal/crypto/session_key_test.go @@ -184,4 +184,3 @@ func TestExtractIP_RemoteAddr(t *testing.T) { t.Errorf("expected 192.168.1.1, got %s", ip) } } - diff --git a/gateway/internal/inject/inject_perf_test.go b/gateway/internal/inject/inject_perf_test.go index 90e04bc..dddcc90 100644 --- a/gateway/internal/inject/inject_perf_test.go +++ b/gateway/internal/inject/inject_perf_test.go @@ -433,10 +433,10 @@ func TestMiddleware_StripsETagAndLastModified(t *testing.T) { func TestMiddleware_BodylessStatusPassThrough(t *testing.T) { cases := []int{ - http.StatusContinue, // 100 - http.StatusNoContent, // 204 - http.StatusNotModified, // 304 - http.StatusSwitchingProtocols, // 101 (1xx range) + http.StatusContinue, // 100 + http.StatusNoContent, // 204 + http.StatusNotModified, // 304 + http.StatusSwitchingProtocols, // 101 (1xx range) } for _, status := range cases { t.Run(http.StatusText(status), func(t *testing.T) { From 7d8eeb5cd386b9203371d85b4af9fb3341679172 Mon Sep 17 00:00:00 2001 From: Ola Adebayo Date: Wed, 30 Sep 2026 19:13:48 +0100 Subject: [PATCH 2/7] fix(gateway): inject manifest defaults for unset variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `.rep.yaml` variable can declare `default:`, and the schema, the manifest guide and the schema reference all say it applies when the variable is unset. The gateway never injected it. `Manifest.Validate` checked required and type, and for an optional, absent variable it `continue`d with nothing else to do (gateway/internal/manifest/manifest.go:150-156 before this change). Nothing downstream read `VarDecl.Default`, so the variable was missing from the payload and `rep.get()` returned undefined. A manifest with `FEATURE_FLAGS: {type: csv, default: ""}` and that variable unset logged `public_vars=2`, not 3. `config.ClassifiedVars.ApplyDefaults` now fills in each optional variable that declares a default and is set in no tier. The value goes into the declared tier under its `REP__` key and goes through the same type and pattern check as a set value (`VarDecl.Check`, split out of `Validate`). `server.New` calls it after validation, so a required variable's default never hides that it is missing, and before the guardrails, so a PUBLIC default is scanned. An empty-string default is still injected. An environment value, even an empty one, always wins. The poller and `Reload` read through a shared `readVars` helper that also applies defaults. Otherwise the poller would compare a defaulted snapshot against a raw one and report a change on every tick, and a reload would drop the default and broadcast a delete. Each default is logged as `rep.manifest.default_applied` (name and tier, not the value). The startup summary counts defaults in the per-tier totals and adds `defaulted_vars`. The `REP__` prefix now has one source, `Tier.Prefix()`, which classification and defaults both use. A default that fails its declared type (for example `type: url, default: ""`) is now a startup error. Before, it was silently never used. Spec: §4.2 step 5 now includes applying defaults, and the new §6.3 states the rules. The docs site's manifest guide, schema reference and startup sequence say the same. --- .../content/docs/concepts/how-it-works.mdx | 2 +- docs/src/content/docs/guides/manifest.mdx | 24 ++- .../docs/reference/manifest-schema.mdx | 4 +- gateway/internal/config/classify.go | 57 +++--- gateway/internal/config/defaults.go | 76 ++++++++ gateway/internal/config/defaults_test.go | 117 ++++++++++++ gateway/internal/manifest/manifest.go | 45 +++-- gateway/internal/server/defaults_test.go | 170 ++++++++++++++++++ gateway/internal/server/env_test.go | 22 +++ gateway/internal/server/server.go | 31 +++- spec/REP-RFC-0001.md | 17 +- 11 files changed, 516 insertions(+), 49 deletions(-) create mode 100644 gateway/internal/config/defaults.go create mode 100644 gateway/internal/config/defaults_test.go create mode 100644 gateway/internal/server/defaults_test.go create mode 100644 gateway/internal/server/env_test.go diff --git a/docs/src/content/docs/concepts/how-it-works.mdx b/docs/src/content/docs/concepts/how-it-works.mdx index 5defc3c..ed9187b 100644 --- a/docs/src/content/docs/concepts/how-it-works.mdx +++ b/docs/src/content/docs/concepts/how-it-works.mdx @@ -50,7 +50,7 @@ The gateway performs these steps in order at process start: 2. Read all `REP_*` environment variables 3. Classify each into PUBLIC, SENSITIVE, or SERVER tier 4. Validate name uniqueness after prefix stripping -5. Load and validate `.rep.yaml` manifest if `--manifest` is specified +5. Load and validate `.rep.yaml` manifest if `--manifest` is specified, then inject the `default` of any optional variable left unset 6. Run secret detection guardrails on PUBLIC variables 7. Exit with error if `--strict` and guardrails triggered 8. Generate ephemeral master key, derive AES-256 key via HKDF-SHA256 diff --git a/docs/src/content/docs/guides/manifest.mdx b/docs/src/content/docs/guides/manifest.mdx index cf00abf..423255f 100644 --- a/docs/src/content/docs/guides/manifest.mdx +++ b/docs/src/content/docs/guides/manifest.mdx @@ -75,7 +75,7 @@ settings: | `tier` | `public` / `sensitive` / `server` | Yes | Security classification | | `type` | see below | Yes | Value type constraint | | `required` | boolean | No | Whether the variable must be present at startup | -| `default` | string | No | Default value if not provided | +| `default` | string | No | Value the gateway injects when the variable is unset. See [Defaults](#defaults) | | `description` | string | No | Human-readable description | | `example` | string | No | Example value | | `pattern` | string | No | Regex pattern the value must match | @@ -95,6 +95,28 @@ settings: | `json` | Must be valid JSON | | `enum` | Must match one of the `values` array entries | +## Defaults + +When a variable declares `default`, isn't `required`, and isn't set in any tier, the gateway injects the default as though it had been set. It goes into the declared `tier`, so a `public` default is in the payload and `rep.get()` returns it: + +```yaml +FEATURE_FLAGS: + tier: public + type: csv + default: "" +``` + +With `REP_PUBLIC_FEATURE_FLAGS` unset, `rep.get('FEATURE_FLAGS')` returns `""`, not `undefined`. + +- An empty string is a default: `default: ""` injects `""`. +- A value in the environment always wins, even an empty one. +- A default is validated against `type` and `pattern` like a set value. A default that doesn't match is a manifest error and the gateway refuses to start. +- A `public` default is scanned by the [guardrails](/concepts/variable-classification/#automatic-secret-detection-guardrails). +- `required: true` wins. A required variable that is unset fails startup even if it declares a default. +- Hot reload re-applies defaults. Removing a variable from the environment reverts it to its default. + +Each injected default is logged as `rep.manifest.default_applied` with its name and tier (never its value). The startup summary counts defaults in `public_vars`, `sensitive_vars` and `server_vars` and totals them in `defaulted_vars`. + ## Settings | Setting | Type | Default | Description | diff --git a/docs/src/content/docs/reference/manifest-schema.mdx b/docs/src/content/docs/reference/manifest-schema.mdx index a423249..a83e724 100644 --- a/docs/src/content/docs/reference/manifest-schema.mdx +++ b/docs/src/content/docs/reference/manifest-schema.mdx @@ -23,7 +23,7 @@ variables: tier: public # Required: public, sensitive, or server type: url # Required: string, url, number, boolean, csv, json, enum required: true # Optional (default: false) - default: "" # Optional — default value if not provided + default: "" # Optional — injected when unset (ignored if required) description: "..." # Optional — human-readable description example: "..." # Optional — example value pattern: "^..." # Optional — regex pattern the value must match @@ -39,7 +39,7 @@ variables: | `tier` | `public` \| `sensitive` \| `server` | Yes | Security classification tier | | `type` | see types table | Yes | Value type constraint | | `required` | `boolean` | No | Must be present at startup (default: `false`) | -| `default` | `string` | No | Default value when variable is absent | +| `default` | `string` | No | Injected into the declared tier when the variable is unset and not `required`. See [Defaults](/guides/manifest/#defaults) | | `description` | `string` | No | Human-readable purpose | | `example` | `string` | No | Example value for documentation | | `pattern` | `string` | No | Regex the value must match | diff --git a/gateway/internal/config/classify.go b/gateway/internal/config/classify.go index d923c26..7a1d6f8 100644 --- a/gateway/internal/config/classify.go +++ b/gateway/internal/config/classify.go @@ -37,6 +37,15 @@ func (t Tier) String() string { } } +// tiers lists every Tier, in classification order. +var tiers = []Tier{TierPublic, TierSensitive, TierServer} + +// Prefix returns the environment variable prefix for the tier, e.g. +// "REP_PUBLIC_". +func (t Tier) Prefix() string { + return "REP_" + strings.ToUpper(t.String()) + "_" +} + // Variable represents a classified environment variable. type Variable struct { // Name is the variable name with the REP__ prefix stripped. @@ -136,21 +145,8 @@ func ReadAndClassify(envFile string) (*ClassifiedVars, error) { continue } - var v Variable - v.OriginalKey = key - v.Value = value - - switch { - case strings.HasPrefix(key, "REP_PUBLIC_"): - v.Name = strings.TrimPrefix(key, "REP_PUBLIC_") - v.Tier = TierPublic - case strings.HasPrefix(key, "REP_SENSITIVE_"): - v.Name = strings.TrimPrefix(key, "REP_SENSITIVE_") - v.Tier = TierSensitive - case strings.HasPrefix(key, "REP_SERVER_"): - v.Name = strings.TrimPrefix(key, "REP_SERVER_") - v.Tier = TierServer - default: + v, ok := classify(key, value) + if !ok { continue } @@ -162,17 +158,30 @@ func ReadAndClassify(envFile string) (*ClassifiedVars, error) { ) } seen[v.Name] = v.OriginalKey + vars.add(v) + } + + return vars, nil +} - // Classify into tier bucket. - switch v.Tier { - case TierPublic: - vars.Public = append(vars.Public, v) - case TierSensitive: - vars.Sensitive = append(vars.Sensitive, v) - case TierServer: - vars.Server = append(vars.Server, v) +// classify returns the Variable for key when it carries a tier prefix. +func classify(key, value string) (Variable, bool) { + for _, t := range tiers { + if name, ok := strings.CutPrefix(key, t.Prefix()); ok { + return Variable{Name: name, Value: value, Tier: t, OriginalKey: key}, true } } + return Variable{}, false +} - return vars, nil +// add appends v to the bucket for its tier. +func (cv *ClassifiedVars) add(v Variable) { + switch v.Tier { + case TierPublic: + cv.Public = append(cv.Public, v) + case TierSensitive: + cv.Sensitive = append(cv.Sensitive, v) + case TierServer: + cv.Server = append(cv.Server, v) + } } diff --git a/gateway/internal/config/defaults.go b/gateway/internal/config/defaults.go new file mode 100644 index 0000000..f341656 --- /dev/null +++ b/gateway/internal/config/defaults.go @@ -0,0 +1,76 @@ +package config + +import ( + "fmt" + "maps" + "slices" + "strings" + + "github.com/ruachtech/rep/gateway/internal/manifest" +) + +// ApplyDefaults fills in the manifest default of every declared variable that +// is optional, declares a default, and is set in no tier. Each default is +// placed in the tier its declaration names, under that tier's REP_* key, and +// is checked against the declared type and pattern exactly as a set value +// is, so from here on it is indistinguishable from one. An empty default is +// still a default. +// +// A required variable is never defaulted: its absence is a startup error +// (manifest.Validate), whatever default it declares. +// +// It returns the variables it added, sorted by name. A nil manifest adds +// nothing. +func (cv *ClassifiedVars) ApplyDefaults(m *manifest.Manifest) ([]Variable, error) { + if m == nil { + return nil, nil + } + + set := make(map[string]bool, len(cv.Public)+len(cv.Sensitive)+len(cv.Server)) + for _, bucket := range [][]Variable{cv.Public, cv.Sensitive, cv.Server} { + for _, v := range bucket { + set[v.Name] = true + } + } + + var added []Variable + var errs []string + for _, name := range slices.Sorted(maps.Keys(m.Variables)) { + decl := m.Variables[name] + if set[name] || decl.Required || !decl.HasDefault { + continue + } + tier, ok := parseTier(decl.Tier) + if !ok { + errs = append(errs, fmt.Sprintf("variable %q declares a default but its tier %q is not public, sensitive or server", name, decl.Tier)) + continue + } + if err := decl.Check(name, decl.Default); err != nil { + errs = append(errs, "default: "+err.Error()) + continue + } + v := Variable{ + Name: name, + Value: decl.Default, + Tier: tier, + OriginalKey: tier.Prefix() + name, + } + cv.add(v) + added = append(added, v) + } + + if len(errs) > 0 { + return nil, fmt.Errorf("invalid default(s):\n - %s", strings.Join(errs, "\n - ")) + } + return added, nil +} + +// parseTier maps a manifest tier name to its Tier. +func parseTier(s string) (Tier, bool) { + for _, t := range tiers { + if s == t.String() { + return t, true + } + } + return 0, false +} diff --git a/gateway/internal/config/defaults_test.go b/gateway/internal/config/defaults_test.go new file mode 100644 index 0000000..a0dd33e --- /dev/null +++ b/gateway/internal/config/defaults_test.go @@ -0,0 +1,117 @@ +package config + +import ( + "reflect" + "strings" + "testing" + + "github.com/ruachtech/rep/gateway/internal/manifest" +) + +func TestApplyDefaults(t *testing.T) { + mk := func(tier Tier, name, value string) Variable { + return Variable{Name: name, Value: value, Tier: tier, OriginalKey: tier.Prefix() + name} + } + pub := func(name, value string) Variable { return mk(TierPublic, name, value) } + + tests := []struct { + name string + vars ClassifiedVars + decls map[string]*manifest.VarDecl + wantErr string + want ClassifiedVars + added int + }{ + { + name: "unset optional variable gets its default", + decls: map[string]*manifest.VarDecl{"FLAGS": {Tier: "public", Type: "csv", Default: "a,b", HasDefault: true}}, + want: ClassifiedVars{Public: []Variable{pub("FLAGS", "a,b")}}, + added: 1, + }, + { + name: "empty-string default is still a default", + decls: map[string]*manifest.VarDecl{"FLAGS": {Tier: "public", Type: "csv", Default: "", HasDefault: true}}, + want: ClassifiedVars{Public: []Variable{pub("FLAGS", "")}}, + added: 1, + }, + { + name: "environment value wins over the default", + vars: ClassifiedVars{Public: []Variable{pub("FLAGS", "from-env")}}, + decls: map[string]*manifest.VarDecl{"FLAGS": {Tier: "public", Type: "csv", Default: "a,b", HasDefault: true}}, + want: ClassifiedVars{Public: []Variable{pub("FLAGS", "from-env")}}, + }, + { + name: "a value set in another tier also wins, so no collision is created", + vars: ClassifiedVars{Server: []Variable{mk(TierServer, "FLAGS", "s")}}, + decls: map[string]*manifest.VarDecl{ + "FLAGS": {Tier: "public", Type: "csv", Default: "a,b", HasDefault: true}, + }, + want: ClassifiedVars{Server: []Variable{mk(TierServer, "FLAGS", "s")}}, + }, + { + name: "required variable is never defaulted", + decls: map[string]*manifest.VarDecl{"ENV": {Tier: "public", Required: true, Default: "prod", HasDefault: true}}, + }, + { + name: "no default declared means nothing is added", + decls: map[string]*manifest.VarDecl{"OPTIONAL": {Tier: "public", Type: "string"}}, + }, + { + name: "defaults land in the tier they declare", + decls: map[string]*manifest.VarDecl{ + "REGION": {Tier: "sensitive", Type: "string", Default: "eu", HasDefault: true}, + "UPSTREAM": {Tier: "server", Type: "string", Default: "api:80", HasDefault: true}, + }, + want: ClassifiedVars{ + Sensitive: []Variable{mk(TierSensitive, "REGION", "eu")}, + Server: []Variable{mk(TierServer, "UPSTREAM", "api:80")}, + }, + added: 2, + }, + { + name: "a default must satisfy the declared type", + decls: map[string]*manifest.VarDecl{"TIMEOUT": {Tier: "public", Type: "number", Default: "soon", HasDefault: true}}, + wantErr: `default: variable "TIMEOUT" must be a number`, + }, + { + name: "a default must satisfy the declared pattern", + decls: map[string]*manifest.VarDecl{"CODE": {Tier: "public", Type: "string", Pattern: `[A-Z]{3}`, Default: "abc", HasDefault: true}}, + wantErr: `default: variable "CODE" value does not match pattern`, + }, + { + name: "a default with no valid tier is refused", + decls: map[string]*manifest.VarDecl{"FLAGS": {Tier: "", Type: "csv", Default: "", HasDefault: true}}, + wantErr: `variable "FLAGS" declares a default but its tier "" is not public, sensitive or server`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + vars := tt.vars + added, err := vars.ApplyDefaults(&manifest.Manifest{Variables: tt.decls}) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("ApplyDefaults() error = %v, want it to contain %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("ApplyDefaults() unexpected error: %v", err) + } + if len(added) != tt.added { + t.Errorf("added %d variables, want %d: %+v", len(added), tt.added, added) + } + if !reflect.DeepEqual(vars, tt.want) { + t.Errorf("vars = %+v, want %+v", vars, tt.want) + } + }) + } +} + +func TestApplyDefaults_NilManifest(t *testing.T) { + var vars ClassifiedVars + added, err := vars.ApplyDefaults(nil) + if err != nil || added != nil { + t.Fatalf("ApplyDefaults(nil) = %v, %v; want nil, nil", added, err) + } +} diff --git a/gateway/internal/manifest/manifest.go b/gateway/internal/manifest/manifest.go index 56c0d63..4a05909 100644 --- a/gateway/internal/manifest/manifest.go +++ b/gateway/internal/manifest/manifest.go @@ -43,8 +43,9 @@ type VarDecl struct { Required bool // Default holds the fallback value when Required is false and the variable - // is absent. HasDefault distinguishes an explicit empty default from - // "no default declared". + // is absent from every tier. The gateway injects it into Tier as if it had + // been set (see config.ClassifiedVars.ApplyDefaults). HasDefault + // distinguishes an explicit empty default from "no default declared". Default string HasDefault bool @@ -151,7 +152,8 @@ func (m *Manifest) Validate(public, sensitive, server map[string]string, log fun if decl.Required { errs = append(errs, fmt.Sprintf("required variable %q is not set", name)) } - // Optional + absent: nothing to validate. + // Optional + absent: a declared default is filled in and checked + // afterwards by config.ClassifiedVars.ApplyDefaults. continue } @@ -166,22 +168,8 @@ func (m *Manifest) Validate(public, sensitive, server map[string]string, log fun } } - // Type validation. - if err := validateType(name, value, decl); err != nil { + if err := decl.Check(name, value); err != nil { errs = append(errs, err.Error()) - continue - } - - // Pattern validation (applies to any type when declared). - if decl.Pattern != "" { - matched, err := regexp.MatchString(`^(?:`+decl.Pattern+`)$`, value) - if err != nil { - errs = append(errs, fmt.Sprintf("variable %q has invalid pattern expression %q: %v", name, decl.Pattern, err)) - continue - } - if !matched { - errs = append(errs, fmt.Sprintf("variable %q value does not match pattern %q", name, decl.Pattern)) - } } } @@ -191,6 +179,27 @@ func (m *Manifest) Validate(public, sensitive, server map[string]string, log fun return nil } +// Check validates value against the declared type and, when one is declared, +// the pattern. It is the single check applied to every value the gateway +// serves for a declared variable — whether it came from the environment or +// from the declaration's default. +func (d *VarDecl) Check(name, value string) error { + if err := validateType(name, value, d); err != nil { + return err + } + if d.Pattern == "" { + return nil + } + matched, err := regexp.MatchString(`^(?:`+d.Pattern+`)$`, value) + if err != nil { + return fmt.Errorf("variable %q has invalid pattern expression %q: %v", name, d.Pattern, err) + } + if !matched { + return fmt.Errorf("variable %q value does not match pattern %q", name, d.Pattern) + } + return nil +} + // validateType checks that value conforms to the declared type. func validateType(name, value string, decl *VarDecl) error { switch decl.Type { diff --git a/gateway/internal/server/defaults_test.go b/gateway/internal/server/defaults_test.go new file mode 100644 index 0000000..f72c7c5 --- /dev/null +++ b/gateway/internal/server/defaults_test.go @@ -0,0 +1,170 @@ +package server + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "log/slog" + "maps" + "net/http/httptest" + "strings" + "testing" + + "github.com/ruachtech/rep/gateway/internal/config" + "github.com/ruachtech/rep/gateway/internal/manifest" + "github.com/ruachtech/rep/gateway/pkg/payload" +) + +// startupLog returns the attributes of the rep.gateway.started log line. +func startupLog(t *testing.T, logs *bytes.Buffer) map[string]any { + t.Helper() + sc := bufio.NewScanner(logs) + for sc.Scan() { + var entry map[string]any + if err := json.Unmarshal(sc.Bytes(), &entry); err != nil { + t.Fatalf("decoding log line %q: %v", sc.Text(), err) + } + if entry["msg"] == "rep.gateway.started" { + return entry + } + } + t.Fatal("no rep.gateway.started log line") + return nil +} + +// injectedPublic serves "/" through the gateway and returns the public map of +// the payload it injected. +func injectedPublic(t *testing.T, s *Server) map[string]string { + t.Helper() + rec := httptest.NewRecorder() + s.httpServer.Handler.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) + + _, tag, ok := bytes.Cut(rec.Body.Bytes(), []byte(`")) + if !ok || !ok2 || !ok3 { + t.Fatalf("no REP payload in response:\n%s", rec.Body.String()) + } + + var p payload.Payload + if err := json.Unmarshal(body, &p); err != nil { + t.Fatalf("decoding payload %q: %v", body, err) + } + return p.Public +} + +// newTestServer builds a gateway from the given manifest declarations. +func newTestServer(decls map[string]*manifest.VarDecl, logger *slog.Logger) (*Server, error) { + return New(&config.Config{ + Mode: "embedded", + StaticDir: "../../testdata/static", + Manifest: &manifest.Manifest{Variables: decls}, + }, logger, "0.0.0-test") +} + +func TestNew_ManifestDefaults(t *testing.T) { + ptr := func(s string) *string { return &s } + + // Every case starts from the manifest shape StageFlow ships: two required + // variables that are set, and a csv flag list that is optional. + tests := []struct { + name string + flagsEnv *string // REP_PUBLIC_FEATURE_FLAGS; nil = unset + flagsDefault string + extra map[string]*manifest.VarDecl + wantErr string + wantFlags string + wantDefaulted float64 + }{ + {name: "empty default is injected and counted", flagsDefault: "", wantFlags: "", wantDefaulted: 1}, + {name: "non-empty default is injected", flagsDefault: "dark-mode,beta", wantFlags: "dark-mode,beta", wantDefaulted: 1}, + {name: "environment value wins over the default", flagsEnv: ptr("new-checkout"), flagsDefault: "dark-mode", wantFlags: "new-checkout"}, + {name: "environment empty string wins over a non-empty default", flagsEnv: ptr(""), flagsDefault: "dark-mode", wantFlags: ""}, + { + name: "required and unset is still an error despite a default", + extra: map[string]*manifest.VarDecl{"REGION": {Tier: "public", Required: true, Default: "eu", HasDefault: true}}, + wantErr: `required variable "REGION" is not set`, + }, + { + name: "a default that fails its declared type is refused", + extra: map[string]*manifest.VarDecl{"STATUS_URL": {Tier: "public", Type: "url", HasDefault: true}}, + wantErr: `variable "STATUS_URL" must be a valid URL`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearREPEnv(t) + t.Setenv("REP_PUBLIC_API_URL", "https://api.example.com") + t.Setenv("REP_PUBLIC_ENV_NAME", "production") + if tt.flagsEnv != nil { + t.Setenv("REP_PUBLIC_FEATURE_FLAGS", *tt.flagsEnv) + } + decls := map[string]*manifest.VarDecl{ + "API_URL": {Tier: "public", Type: "url", Required: true}, + "ENV_NAME": {Tier: "public", Type: "string", Required: true}, + "FEATURE_FLAGS": {Tier: "public", Type: "csv", Default: tt.flagsDefault, HasDefault: true}, + } + maps.Copy(decls, tt.extra) + + var logs bytes.Buffer + s, err := newTestServer(decls, slog.New(slog.NewJSONHandler(&logs, nil))) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("New() error = %v, want it to contain %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("New() unexpected error: %v", err) + } + + want := map[string]string{ + "API_URL": "https://api.example.com", + "ENV_NAME": "production", + "FEATURE_FLAGS": tt.wantFlags, + } + if got := injectedPublic(t, s); !maps.Equal(got, want) { + t.Errorf("payload public = %q, want %q", got, want) + } + + entry := startupLog(t, &logs) + if entry["public_vars"] != float64(len(want)) { + t.Errorf("startup log public_vars = %v, want %d", entry["public_vars"], len(want)) + } + if entry["defaulted_vars"] != tt.wantDefaulted { + t.Errorf("startup log defaulted_vars = %v, want %v", entry["defaulted_vars"], tt.wantDefaulted) + } + }) + } +} + +// A default must survive a reload, and must not look like a change to the +// poller on every tick. +func TestReload_KeepsManifestDefaults(t *testing.T) { + clearREPEnv(t) + t.Setenv("REP_PUBLIC_API_URL", "https://api.example.com") + + s, err := newTestServer(map[string]*manifest.VarDecl{ + "FEATURE_FLAGS": {Tier: "public", Type: "csv", Default: "", HasDefault: true}, + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("New() unexpected error: %v", err) + } + + polled, err := s.readVars() + if err != nil { + t.Fatalf("readVars() unexpected error: %v", err) + } + if varsChanged(s.vars, polled) { + t.Error("poller sees a change on an unchanged environment") + } + + if err := s.Reload(); err != nil { + t.Fatalf("Reload() unexpected error: %v", err) + } + if _, ok := injectedPublic(t, s)["FEATURE_FLAGS"]; !ok { + t.Error("FEATURE_FLAGS missing from the payload after reload") + } +} diff --git a/gateway/internal/server/env_test.go b/gateway/internal/server/env_test.go new file mode 100644 index 0000000..4d24468 --- /dev/null +++ b/gateway/internal/server/env_test.go @@ -0,0 +1,22 @@ +package server + +import ( + "os" + "strings" + "testing" +) + +// clearREPEnv removes every REP_* variable from the process environment for +// the duration of the test, so server.New sees only what the test sets. +func clearREPEnv(t *testing.T) { + t.Helper() + for _, env := range os.Environ() { + key, _, _ := strings.Cut(env, "=") + if strings.HasPrefix(key, "REP_") { + t.Setenv(key, "") // registers restoration on cleanup + if err := os.Unsetenv(key); err != nil { + t.Fatalf("unsetenv %q: %v", key, err) + } + } + } +} diff --git a/gateway/internal/server/server.go b/gateway/internal/server/server.go index ac9b7a2..eda6a3d 100644 --- a/gateway/internal/server/server.go +++ b/gateway/internal/server/server.go @@ -77,6 +77,18 @@ func New(cfg *config.Config, logger *slog.Logger, version string) (*Server, erro } } + // Step 2c: Fill in manifest defaults for optional variables left unset + // (§6.3). After validation, which judges only what the environment set + // (a defaulted deprecated variable is not "present"); before guardrails, + // so a PUBLIC default is scanned. + defaulted, err := vars.ApplyDefaults(cfg.Manifest) + if err != nil { + return nil, fmt.Errorf("manifest validation: %w", err) + } + for _, v := range defaulted { + logger.Info("rep.manifest.default_applied", "name", v.Name, "tier", v.Tier.String()) + } + // Step 3–4: Run secret detection guardrails. logger.Info("running guardrail scan on PUBLIC tier variables") gr := guardrails.Scan(vars, logger) @@ -197,6 +209,7 @@ func New(cfg *config.Config, logger *slog.Logger, version string) (*Server, erro "public_vars", len(vars.Public), "sensitive_vars", len(vars.Sensitive), "server_vars", len(vars.Server), + "defaulted_vars", len(defaulted), "guardrail_warnings", len(gr.Warnings), "hot_reload", cfg.HotReload, "strict", cfg.Strict, @@ -349,7 +362,7 @@ func (s *Server) runPoller(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - newVars, err := config.ReadAndClassify(s.cfg.EnvFile) + newVars, err := s.readVars() if err != nil { s.logger.Error("rep.hotreload.poll.classify_error", "error", err) continue @@ -395,7 +408,7 @@ func (s *Server) Reload() error { s.logger.Info("reloading configuration") // Re-read and classify. - vars, err := config.ReadAndClassify(s.cfg.EnvFile) + vars, err := s.readVars() if err != nil { return fmt.Errorf("re-classifying variables: %w", err) } @@ -430,6 +443,20 @@ func (s *Server) Reload() error { return nil } +// readVars re-reads and classifies the environment and fills in manifest +// defaults, so a reload or a poll sees the same variable set startup built. +// Manifest validation is a startup-only step and is not repeated here. +func (s *Server) readVars() (*config.ClassifiedVars, error) { + vars, err := config.ReadAndClassify(s.cfg.EnvFile) + if err != nil { + return nil, err + } + if _, err := vars.ApplyDefaults(s.cfg.Manifest); err != nil { + return nil, err + } + return vars, nil +} + // broadcastChanges compares old and new variables and emits SSE events. func (s *Server) broadcastChanges(oldVars, newVars *config.ClassifiedVars) { oldPublic := oldVars.PublicMap() diff --git a/spec/REP-RFC-0001.md b/spec/REP-RFC-0001.md index 01ec66f..ce546fa 100644 --- a/spec/REP-RFC-0001.md +++ b/spec/REP-RFC-0001.md @@ -150,6 +150,8 @@ On process start, the gateway MUST perform the following steps in order: 5. IF --manifest specified → LOAD the .rep.yaml manifest and validate all declared variables against the environment (required vars present, types match, patterns match). On validation failure → EXIT with error. + Then INJECT the `default` of every declared variable that is not + required and is unset in every tier (see §6.3). 6. RUN secret detection guardrails on PUBLIC tier variables 7. IF --strict AND guardrails triggered → EXIT with error 8. GENERATE ephemeral master key, derive AES-256 encryption key via @@ -163,7 +165,8 @@ On process start, the gateway MUST perform the following steps in order: - Health check endpoint (/rep/health) - Hot reload SSE endpoint (/rep/changes) [if enabled] 13. START accepting connections -14. LOG startup summary: variable counts per tier, any guardrail warnings +14. LOG startup summary: variable counts per tier (injected defaults + included), any guardrail warnings ``` ### 4.3 HTML Injection @@ -489,6 +492,18 @@ settings: | `json` | Must be valid JSON | | `enum` | Must match one of the values in the `values` array | +### 6.3 Defaults + +A variable that declares `default` and is not `required` has a value whether or not the environment sets one. When no variable of that name is set in any tier, the gateway MUST inject the default as though `REP__` had been set to it, where `` is the declared `tier`: + +- A PUBLIC default appears in the payload and is readable with `rep.get()`; a SENSITIVE default is encrypted like any other SENSITIVE value; a SERVER default stays in the gateway. +- A default MUST pass the same type and pattern validation as a set value. A default that fails is a manifest error and the gateway MUST refuse to start. +- A PUBLIC default is scanned by the guardrails (§3.3) like any other PUBLIC value. +- An empty string is a default: `default: ""` injects `""`. +- A value in the environment always wins, including an empty one. +- `required: true` takes precedence. A required variable that is unset is a startup error even if it declares a default. +- Defaults are re-applied on every hot reload, so removing a variable from the environment reverts it to its default rather than deleting it. + --- ## 7. Gateway Configuration From 2725f5b30e3808202b125adc94b1042140f712d5 Mon Sep 17 00:00:00 2001 From: Ola Adebayo Date: Wed, 30 Sep 2026 19:49:35 +0100 Subject: [PATCH 3/7] fix(gateway): validate every manifest default, and apply them atomically Review follow-up. ApplyDefaults only checked a default it was about to inject, so an invalid default was accepted at startup while the environment overrode it. It surfaced later: remove the override, and the next reload failed while the gateway kept serving the old value. Every optional default is now checked at startup, whether or not it is in use. Defaults are now added only after all of them pass. Before, a valid default could be appended to the receiver before a later invalid one returned an error. Every example manifest in the repo still loads and passes. --- docs/src/content/docs/guides/manifest.mdx | 2 +- gateway/internal/config/defaults.go | 24 ++++++++++++----------- gateway/internal/config/defaults_test.go | 21 ++++++++++++++++++++ spec/REP-RFC-0001.md | 2 +- 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/docs/src/content/docs/guides/manifest.mdx b/docs/src/content/docs/guides/manifest.mdx index 423255f..9d8a497 100644 --- a/docs/src/content/docs/guides/manifest.mdx +++ b/docs/src/content/docs/guides/manifest.mdx @@ -110,7 +110,7 @@ With `REP_PUBLIC_FEATURE_FLAGS` unset, `rep.get('FEATURE_FLAGS')` returns `""`, - An empty string is a default: `default: ""` injects `""`. - A value in the environment always wins, even an empty one. -- A default is validated against `type` and `pattern` like a set value. A default that doesn't match is a manifest error and the gateway refuses to start. +- A default is validated against `type` and `pattern` like a set value, even while the environment overrides it. A default that doesn't match is a manifest error and the gateway refuses to start. - A `public` default is scanned by the [guardrails](/concepts/variable-classification/#automatic-secret-detection-guardrails). - `required: true` wins. A required variable that is unset fails startup even if it declares a default. - Hot reload re-applies defaults. Removing a variable from the environment reverts it to its default. diff --git a/gateway/internal/config/defaults.go b/gateway/internal/config/defaults.go index f341656..3e1edc8 100644 --- a/gateway/internal/config/defaults.go +++ b/gateway/internal/config/defaults.go @@ -11,11 +11,15 @@ import ( // ApplyDefaults fills in the manifest default of every declared variable that // is optional, declares a default, and is set in no tier. Each default is -// placed in the tier its declaration names, under that tier's REP_* key, and -// is checked against the declared type and pattern exactly as a set value -// is, so from here on it is indistinguishable from one. An empty default is +// placed in the tier its declaration names, under that tier's REP_* key, so +// from here on it is indistinguishable from a set value. An empty default is // still a default. // +// Every optional default is checked against its declared tier, type and +// pattern — including one the environment currently overrides, because +// removing that override later (a reload) would otherwise surface a broken +// default mid-flight. Nothing is added unless every default passes. +// // A required variable is never defaulted: its absence is a startup error // (manifest.Validate), whatever default it declares. // @@ -37,7 +41,7 @@ func (cv *ClassifiedVars) ApplyDefaults(m *manifest.Manifest) ([]Variable, error var errs []string for _, name := range slices.Sorted(maps.Keys(m.Variables)) { decl := m.Variables[name] - if set[name] || decl.Required || !decl.HasDefault { + if decl.Required || !decl.HasDefault { continue } tier, ok := parseTier(decl.Tier) @@ -49,19 +53,17 @@ func (cv *ClassifiedVars) ApplyDefaults(m *manifest.Manifest) ([]Variable, error errs = append(errs, "default: "+err.Error()) continue } - v := Variable{ - Name: name, - Value: decl.Default, - Tier: tier, - OriginalKey: tier.Prefix() + name, + if !set[name] { + added = append(added, Variable{Name: name, Value: decl.Default, Tier: tier, OriginalKey: tier.Prefix() + name}) } - cv.add(v) - added = append(added, v) } if len(errs) > 0 { return nil, fmt.Errorf("invalid default(s):\n - %s", strings.Join(errs, "\n - ")) } + for _, v := range added { + cv.add(v) + } return added, nil } diff --git a/gateway/internal/config/defaults_test.go b/gateway/internal/config/defaults_test.go index a0dd33e..f7df10c 100644 --- a/gateway/internal/config/defaults_test.go +++ b/gateway/internal/config/defaults_test.go @@ -78,6 +78,12 @@ func TestApplyDefaults(t *testing.T) { decls: map[string]*manifest.VarDecl{"CODE": {Tier: "public", Type: "string", Pattern: `[A-Z]{3}`, Default: "abc", HasDefault: true}}, wantErr: `default: variable "CODE" value does not match pattern`, }, + { + name: "an invalid default is refused even while the environment overrides it", + vars: ClassifiedVars{Public: []Variable{pub("TIMEOUT", "30")}}, + decls: map[string]*manifest.VarDecl{"TIMEOUT": {Tier: "public", Type: "number", Default: "soon", HasDefault: true}}, + wantErr: `default: variable "TIMEOUT" must be a number`, + }, { name: "a default with no valid tier is refused", decls: map[string]*manifest.VarDecl{"FLAGS": {Tier: "", Type: "csv", Default: "", HasDefault: true}}, @@ -108,6 +114,21 @@ func TestApplyDefaults(t *testing.T) { } } +// On error the receiver is left untouched, even by defaults that were valid. +func TestApplyDefaults_ErrorAddsNothing(t *testing.T) { + var vars ClassifiedVars + _, err := vars.ApplyDefaults(&manifest.Manifest{Variables: map[string]*manifest.VarDecl{ + "A_FLAGS": {Tier: "public", Type: "csv", Default: "ok", HasDefault: true}, + "B_PORT": {Tier: "public", Type: "number", Default: "nope", HasDefault: true}, + }}) + if err == nil { + t.Fatal("ApplyDefaults() error = nil, want an invalid-default error") + } + if !reflect.DeepEqual(vars, ClassifiedVars{}) { + t.Errorf("vars = %+v after an error, want them untouched", vars) + } +} + func TestApplyDefaults_NilManifest(t *testing.T) { var vars ClassifiedVars added, err := vars.ApplyDefaults(nil) diff --git a/spec/REP-RFC-0001.md b/spec/REP-RFC-0001.md index ce546fa..80a8e83 100644 --- a/spec/REP-RFC-0001.md +++ b/spec/REP-RFC-0001.md @@ -497,7 +497,7 @@ settings: A variable that declares `default` and is not `required` has a value whether or not the environment sets one. When no variable of that name is set in any tier, the gateway MUST inject the default as though `REP__` had been set to it, where `` is the declared `tier`: - A PUBLIC default appears in the payload and is readable with `rep.get()`; a SENSITIVE default is encrypted like any other SENSITIVE value; a SERVER default stays in the gateway. -- A default MUST pass the same type and pattern validation as a set value. A default that fails is a manifest error and the gateway MUST refuse to start. +- A default MUST pass the same type and pattern validation as a set value, whether or not the environment currently overrides it. A default that fails is a manifest error and the gateway MUST refuse to start. - A PUBLIC default is scanned by the guardrails (§3.3) like any other PUBLIC value. - An empty string is a default: `default: ""` injects `""`. - A value in the environment always wins, including an empty one. From 45e0b19f085527345303b4b030174e4671f4e3d9 Mon Sep 17 00:00:00 2001 From: Ola Adebayo Date: Thu, 1 Oct 2026 13:18:09 +0100 Subject: [PATCH 4/7] docs(spec): bump REP-RFC-0001 to 0.2.0 with a revision history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a normative rule to the RFC (§4.2 step 5, §6.3: the gateway MUST inject manifest defaults). Under the published versioning policy (docs/src/content/docs/spec/index.mdx), patch is reserved for clarifications and non-normative additions, and minor covers backwards-compatible extensions. So this is a minor bump, 0.1.0 to 0.2.0, with `Updated:` set to the date of the change. The RFC had no revision history. Appendix C now records each version, and the docs site's RFC page links to it rather than duplicating it. The spec index lists the core protocol at 0.2.0 and makes clear that the security model and conformance documents are still 0.1.0. The protocol version is documentation only. The payload's `_meta.version` and `data-rep-version` carry the gateway build version, and nothing compares a manifest's `version:` field. So existing manifests and SDKs are unaffected. --- docs/src/content/docs/spec/index.mdx | 4 ++-- docs/src/content/docs/spec/rfc-0001.mdx | 9 +++++++-- spec/REP-RFC-0001.md | 15 +++++++++++++-- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/docs/src/content/docs/spec/index.mdx b/docs/src/content/docs/spec/index.mdx index 6959890..08e7168 100644 --- a/docs/src/content/docs/spec/index.mdx +++ b/docs/src/content/docs/spec/index.mdx @@ -9,7 +9,7 @@ REP is defined by three specification documents. They are the authoritative refe | Document | Status | Version | Description | |---|---|---|---| -| [REP-RFC-0001](/spec/rfc-0001/) | Active | 0.1.0 | Core protocol specification — variable classification, gateway architecture, SDK API, wire format, deployment patterns | +| [REP-RFC-0001](/spec/rfc-0001/) | Active | 0.2.0 | Core protocol specification — variable classification, gateway architecture, SDK API, wire format, deployment patterns | | [Security Model](/spec/security-model/) | Active | 0.1.0 | Threat model, 7 threat analyses, hardening recommendations, known limitations | | [Conformance](/spec/conformance/) | Active | 0.1.0 | Requirements for conformant gateway and SDK implementations | @@ -21,7 +21,7 @@ The specification uses semantic versioning: - **Minor** (0.x.0): New optional features, backwards-compatible extensions - **Major** (x.0.0): Breaking changes to the wire format, API surface, or security model -The current version (0.1.0) indicates the specification is active and subject to refinement based on implementation experience. Breaking changes are possible before 1.0. +The core protocol is at 0.2.0 (see its revision history, Appendix C); the security model and conformance documents remain at 0.1.0. Pre-1.0 versions indicate the specification is active and subject to refinement based on implementation experience. Breaking changes are possible before 1.0. ## License diff --git a/docs/src/content/docs/spec/rfc-0001.mdx b/docs/src/content/docs/spec/rfc-0001.mdx index f7ba2bc..3d2959f 100644 --- a/docs/src/content/docs/spec/rfc-0001.mdx +++ b/docs/src/content/docs/spec/rfc-0001.mdx @@ -1,16 +1,17 @@ --- title: REP-RFC-0001 — Core Protocol Specification -description: The core protocol specification for the Runtime Environment Protocol v0.1.0. Variable classification, gateway behaviour, SDK API, wire format, encryption, and deployment patterns. +description: The core protocol specification for the Runtime Environment Protocol v0.2.0. Variable classification, gateway behaviour, SDK API, wire format, encryption, and deployment patterns. --- import { Aside, LinkCard } from '@astrojs/starlight/components'; ``` Title: Runtime Environment Protocol (REP) -Version: 0.1.0 +Version: 0.2.0 Status: Active Authors: Olamide Adebayo (Ruach Tech) Created: 2026-02-18 +Updated: 2026-10-01 License: CC BY 4.0 ``` @@ -38,6 +39,10 @@ The full specification covers 14 sections. Key topics are documented separately +## Revision history + +The canonical document records every version and what changed in it: see [Appendix C: Revision History](https://github.com/ruachtech/rep/blob/main/spec/REP-RFC-0001.md#appendix-c-revision-history). + ## Design requirements | ID | Requirement | diff --git a/spec/REP-RFC-0001.md b/spec/REP-RFC-0001.md index 80a8e83..16c09ea 100644 --- a/spec/REP-RFC-0001.md +++ b/spec/REP-RFC-0001.md @@ -2,11 +2,11 @@ ``` Title: Runtime Environment Protocol (REP) -Version: 0.1.0 +Version: 0.2.0 Status: Active Authors: Olamide Adebayo (Ruach Tech) Created: 2026-02-18 -Updated: 2026-02-21 +Updated: 2026-10-01 License: CC BY 4.0 ``` @@ -887,4 +887,15 @@ A: REP requires a compute layer (the gateway) between the CDN and the client. Fo --- +## Appendix C: Revision History + +Versions follow the specification's versioning policy: patch for clarifications and non-normative additions, minor for new optional features and backwards-compatible extensions, major for breaking changes. The protocol version is independent of implementation versions; the payload's `_meta.version` carries the gateway's build version. + +| Version | Date | Changes | +|---|---|---| +| 0.2.0 | 2026-10-01 | §4.2 step 5 and new §6.3: the gateway MUST inject a manifest `default` for an optional variable that is unset in every tier, validated like a set value. | +| 0.1.0 | 2026-02-18 | Initial publication. | + +--- + *End of REP-RFC-0001* From da18de51787e491ff5c2640947d33ad1b8426211 Mon Sep 17 00:00:00 2001 From: Ola Adebayo Date: Thu, 1 Oct 2026 13:21:38 +0100 Subject: [PATCH 5/7] docs: document manifest default injection on every user-facing surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited every place that describes manifests, startup or logs, so each one states the new behaviour: - gateway `--help`: `--manifest` says it validates and injects defaults. - gateway README: adds the missing `--manifest` row, a manifest step in the architecture diagram, "targets REP-RFC-0001 v0.2.0", and an "Upgrading / To 0.1.8" section (defaults now injected; a default that fails its own type is now a startup error; `defaulted_vars` and `rep.manifest.default_applied`). - Manifest guide: an "Upgrading to gateway 0.1.8" caution. - Gateway flags, health endpoint (counts include defaults), hot reload (a removed variable with a default updates rather than deletes), agents playbook, and the security-model monitoring table. - Manifest JSON schema `default` description, in all three tracked copies (schema/, cli/schema/, docs/public/schema/), kept identical. - CLI docs: `rep validate` checks structure, not default-vs-type. - RFC §11.3 and the conformance page, and the conformance version in the spec index, now 0.2.0 like the RFC it is extracted from. - examples/.rep.yaml: a comment on the injected empty default. SECURITY-MODEL.md is left unchanged. It is versioned separately and still accurate. --- cli/README.md | 2 ++ cli/schema/rep-manifest.schema.json | 2 +- docs/public/schema/rep-manifest.schema.json | 2 +- docs/src/content/docs/agents.mdx | 2 ++ docs/src/content/docs/concepts/hot-reload.mdx | 2 ++ docs/src/content/docs/concepts/security-model.mdx | 1 + docs/src/content/docs/guides/manifest.mdx | 4 ++++ docs/src/content/docs/reference/cli.mdx | 2 ++ .../src/content/docs/reference/gateway-endpoints.mdx | 2 ++ docs/src/content/docs/reference/gateway-flags.mdx | 2 +- docs/src/content/docs/spec/conformance.mdx | 2 +- docs/src/content/docs/spec/index.mdx | 4 ++-- examples/.rep.yaml | 2 +- gateway/README.md | 12 +++++++++++- gateway/internal/config/config.go | 2 +- schema/rep-manifest.schema.json | 2 +- spec/REP-RFC-0001.md | 4 ++-- 17 files changed, 37 insertions(+), 12 deletions(-) diff --git a/cli/README.md b/cli/README.md index 0d27cc1..ed39de1 100644 --- a/cli/README.md +++ b/cli/README.md @@ -22,6 +22,8 @@ npx @rep-protocol/cli [command] Validate a `.rep.yaml` manifest file against the JSON schema. +This checks structure only. It does not check a `default` against its `type` or `pattern`; the gateway does that at startup and refuses to start on a mismatch. + ```bash rep validate --manifest .rep.yaml ``` diff --git a/cli/schema/rep-manifest.schema.json b/cli/schema/rep-manifest.schema.json index 4e57203..f679a91 100644 --- a/cli/schema/rep-manifest.schema.json +++ b/cli/schema/rep-manifest.schema.json @@ -39,7 +39,7 @@ }, "default": { "type": ["string", "number", "boolean"], - "description": "Default value if the environment variable is not set. Only valid for non-required variables. Non-string values are coerced to strings." + "description": "Value the gateway injects, into the declared tier, when the variable is unset in every tier. Ignored for required variables. Must satisfy the declared type and pattern, or the gateway refuses to start. An empty string is a valid default. Non-string values are coerced to strings." }, "description": { "type": "string", diff --git a/docs/public/schema/rep-manifest.schema.json b/docs/public/schema/rep-manifest.schema.json index 4e57203..f679a91 100644 --- a/docs/public/schema/rep-manifest.schema.json +++ b/docs/public/schema/rep-manifest.schema.json @@ -39,7 +39,7 @@ }, "default": { "type": ["string", "number", "boolean"], - "description": "Default value if the environment variable is not set. Only valid for non-required variables. Non-string values are coerced to strings." + "description": "Value the gateway injects, into the declared tier, when the variable is unset in every tier. Ignored for required variables. Must satisfy the declared type and pattern, or the gateway refuses to start. An empty string is a valid default. Non-string values are coerced to strings." }, "description": { "type": "string", diff --git a/docs/src/content/docs/agents.mdx b/docs/src/content/docs/agents.mdx index 53b4713..0a32a5f 100644 --- a/docs/src/content/docs/agents.mdx +++ b/docs/src/content/docs/agents.mdx @@ -366,6 +366,8 @@ settings: allowed_origins: ["https://app.example.com"] ``` +An optional variable that declares `default:` is injected by the gateway when it is unset, so `rep.get('FEATURE_FLAGS')` above returns `""` rather than `undefined`. The default must satisfy its own `type`, or the gateway refuses to start. + ```bash rep validate # fail fast on a malformed or incomplete manifest rep typegen -o src/rep.d.ts # typed get() / getSecure() overloads diff --git a/docs/src/content/docs/concepts/hot-reload.mdx b/docs/src/content/docs/concepts/hot-reload.mdx index e98fcf6..2752a44 100644 --- a/docs/src/content/docs/concepts/hot-reload.mdx +++ b/docs/src/content/docs/concepts/hot-reload.mdx @@ -40,6 +40,8 @@ id: 1708267831000 | `rep:config:update` | A variable's value changed or a new variable was added | | `rep:config:delete` | A variable was removed | +Manifest [defaults](/guides/manifest/#defaults) are re-applied on every reload. Removing a variable that declares a default sends `rep:config:update` with the default value, not `rep:config:delete`. + ## Change detection modes The gateway supports three modes for detecting environment variable changes: diff --git a/docs/src/content/docs/concepts/security-model.mdx b/docs/src/content/docs/concepts/security-model.mdx index 73645d1..b9a3635 100644 --- a/docs/src/content/docs/concepts/security-model.mdx +++ b/docs/src/content/docs/concepts/security-model.mdx @@ -150,6 +150,7 @@ The `