diff --git a/cli/README.md b/cli/README.md index 0d27cc1..47c3dea 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. +Structural check only: the gateway, not `rep validate`, checks each [default](https://rep-protocol.dev/guides/manifest/#defaults) against its `type` and `pattern`. + ```bash rep validate --manifest .rep.yaml ``` diff --git a/cli/schema/rep-manifest.schema.json b/cli/schema/rep-manifest.schema.json index 4e57203..212512f 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": "Injected by the gateway when the variable is unset in every tier; ignored if required. Must satisfy type and pattern, or the gateway refuses to start. 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..212512f 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": "Injected by the gateway when the variable is unset in every tier; ignored if required. Must satisfy type and pattern, or the gateway refuses to start. 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..3b464df 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](/guides/manifest.md#defaults) when it is unset, so `rep.get('FEATURE_FLAGS')` above returns `""` rather than `undefined`. + ```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/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/concepts/security-model.mdx b/docs/src/content/docs/concepts/security-model.mdx index 73645d1..c41994f 100644 --- a/docs/src/content/docs/concepts/security-model.mdx +++ b/docs/src/content/docs/concepts/security-model.mdx @@ -150,6 +150,7 @@ The `")) + 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..244418d 100644 --- a/gateway/internal/server/server.go +++ b/gateway/internal/server/server.go @@ -77,6 +77,15 @@ 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") and has already + // checked every default; before guardrails, so a PUBLIC default is scanned. + defaulted := vars.ApplyDefaults(cfg.Manifest) + 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 +206,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 +359,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 +405,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 +440,18 @@ 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 + } + vars.ApplyDefaults(s.cfg.Manifest) + 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/schema/rep-manifest.schema.json b/schema/rep-manifest.schema.json index 4e57203..212512f 100644 --- a/schema/rep-manifest.schema.json +++ b/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": "Injected by the gateway when the variable is unset in every tier; ignored if required. Must satisfy type and pattern, or the gateway refuses to start. Non-string values are coerced to strings." }, "description": { "type": "string", diff --git a/spec/REP-RFC-0001.md b/spec/REP-RFC-0001.md index 01ec66f..2afc649 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 ``` @@ -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, 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. +- `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 @@ -800,7 +815,7 @@ An implementation is **REP-conformant** if it satisfies the following: ### 11.3 Optional Features (MAY) 1. Hot reload via SSE. -2. Manifest validation. +2. Manifest validation and default injection (§6.3). 3. Type generation. 4. Framework-specific adapters. 5. Codemod tooling. @@ -872,4 +887,15 @@ A: REP requires a compute layer (the gateway) between the CDN and the client. Fo --- +## Appendix C: Revision History + +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, new §6.3 and §11.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*