Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ import { rep } from '@rep-protocol/sdk';

// PUBLIC vars — synchronous, no async, no loading state
const apiUrl = rep.get('API_URL');
const flags = rep.get('FEATURE_FLAGS');
const flags = rep.get('FEATURE_FLAGS'); // see rep-protocol.dev/guides/feature-flags/

// SENSITIVE vars — encrypted, decrypted on demand
const key = await rep.getSecure('ANALYTICS_KEY');
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ When this page is not enough, these are the pages to read next — each is avail
- [Quick Start](/quick-start.md) — the five-minute path
- [How REP Works](/concepts/how-it-works.md) — startup sequence and injection mechanics
- [Variable Classification](/concepts/variable-classification.md) — tier rules and guardrail internals
- [Feature Flags](/guides/feature-flags.md) — one `csv` variable, flipped without a rebuild
- [Security Model](/concepts/security-model.md) — threat analyses and hardening
- [Wire Format](/concepts/wire-format.md) — payload JSON, encrypted blob layout, HMAC
- [SDK API](/reference/sdk.md) — full client reference
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/concepts/variable-classification.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ At startup, the gateway scans all `REP_PUBLIC_*` values for patterns indicating
| **Known key formats** | AWS keys (`AKIA...`), JWTs (`eyJ...`), GitHub tokens (`ghp_...`), Stripe keys (`sk_live_...`), private keys (`-----BEGIN`) |
| **Length anomalies** | Strings > 64 characters that may be encoded secrets |

A variable declared `type: csv` in the [manifest](/guides/manifest/) is scanned one element at a time (split on commas, whitespace trimmed) rather than as one string. A long list of short feature flags therefore doesn't trip the length check under `--strict`. A long or secret-shaped token inside the list is still flagged, and the warning names its position (`csv element 3: …` in the message, `csv_element` in the log). Every other value is scanned whole, and so is every value in the dev plugins, which [don't read the manifest](/guides/feature-flags/#dev-plugins).

When a potential misclassification is detected:

- The gateway logs a **WARNING** with the variable name (never the value)
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/guides/development.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ The easiest way to get full REP support in development. The plugin injects the s
</Tabs>

<Aside>
Both plugins run guardrails on PUBLIC vars and warn if they look like secrets. Use the `strict` option to make warnings into errors.
Both plugins run guardrails on PUBLIC vars and warn if they look like secrets. Use the `strict` option to make warnings into errors. Unlike the gateway, they [scan `csv` values whole](/guides/feature-flags/#dev-plugins).
</Aside>

## Option B: Default values (simplest)
Expand Down
88 changes: 88 additions & 0 deletions docs/src/content/docs/guides/feature-flags.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
title: Feature Flags — One csv Variable, Flipped Without a Rebuild
description: Use a single REP_PUBLIC_FEATURE_FLAGS csv variable for deployment-wide feature flags. Manifest declaration, SDK reads, guardrail behaviour, flipping flags without a rebuild, and what REP flags are not.
---

import { Aside } from '@astrojs/starlight/components';

REP can carry simple feature flags: switches that are on or off for a whole deployment. Use one `csv` variable that lists the flags that are on.

## Declare one variable

```yaml
# .rep.yaml
variables:
Comment on lines +12 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Complete the manifest example

The YAML labeled .rep.yaml omits the required top-level version. Copying it as a manifest makes rep validate fail before readers can use the feature flags. Add a version or clearly label the YAML as a fragment of an existing manifest.

Artifacts

Feature-flags manifest validation script

  • The authored script extracts the guide’s YAML and invokes the CLI before and after adding only the required version, so both runs can be reproduced.

Copied manifest fails validation

  • The captured CLI run exited 1 and reported the missing required `version` property, confirming the copied example fails.

Manifest with version passes validation

  • The captured CLI run exited 0 after adding only `version: "0.1.0"`, isolating the omission.

View artifacts

T-Rex Ran code and verified through T-Rex

FEATURE_FLAGS:
tier: public
type: csv
default: ""
pattern: "([a-z0-9-]+(,[a-z0-9-]+)*)?" # optional: kebab-case names, no spaces
description: "Flags that are on, comma-separated"
```

Use one variable for all flags, not one per flag. Adding a flag is then a value change, not a manifest change.

With `default: ""`, an unset `REP_PUBLIC_FEATURE_FLAGS` becomes an empty list, so every flag is off (see [Defaults](/guides/manifest/#defaults)). The optional `pattern` rejects typos such as stray spaces or capitals at startup.

## Read it with the SDK

```typescript
import { rep } from '@rep-protocol/sdk';

const enabled = new Set(
rep.get('FEATURE_FLAGS', '').split(',').map((f) => f.trim()).filter(Boolean),
);

export const isOn = (flag: string) => enabled.has(flag);
Comment on lines +32 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Flag checks stay stale

The guide constructs enabled only once. After a hot-reload update, the SDK has the new flags, but the example’s isOn still checks the original Set. Readers following the example can make stale flag decisions until the page reloads.

Artifacts

Chromium verification source

  • The authored script renders the verbatim guide snippet with the real SDK and sends an SSE flag update, establishing the executed source.

Chromium check output

  • The command output records its working directory, exit code, SDK values, and flag results before and after the SSE update, confirming the stale result.

▶ Guide example before hot reload

  • Chromium renders the guide snippet with only `landing-page` enabled, showing `new-dashboard` off before the update.

Guide example before hot reload poster

  • The captured browser frame shows the initial SDK value and the guide's matching off result.

▶ Guide example after SDK hot reload

  • Chromium renders the same snippet after an SSE update, showing that the SDK includes `new-dashboard` while `isOn` still reports off.

Guide example after hot reload poster

  • The captured browser frame shows the updated SDK value beside the stale guide result.

View artifacts

T-Rex Ran code and verified through T-Rex

```

A flag is **off unless it is named**. There is no list of known flags to keep in sync and no "false" value to set.

The second argument to `rep.get()` covers the case with no gateway at all, such as unit tests or a plain `vite dev`. Behind the gateway, the manifest default already guarantees a string.

For example, a single image might serve a marketing landing page at `/` on its hosted deployment and redirect `/` straight to sign-in everywhere else:

```bash
# hosted deployment
REP_PUBLIC_FEATURE_FLAGS=landing-page
# self-hosted deployment: leave it unset — every flag is off
```

```typescript
if (location.pathname === '/' && !isOn('landing-page')) location.replace('/login');
```

## Flags are public

`FEATURE_FLAGS` is a PUBLIC variable, so its value is in the page source of every page the gateway serves.

- **Never put a secret in a flag name.** That includes a token, a customer name, or an unreleased product's codename you can't disclose.
- **A flag is not access control.** Hiding a button does not stop anyone calling the API behind it, so the server must still check permissions.

## Guardrails judge each flag, not the list

The gateway's [guardrails](/concepts/variable-classification/#automatic-secret-detection-guardrails) check a `csv` value one element at a time. A long list of short flags can't make `--strict` refuse to start, and an element that looks like a secret is still flagged.

### Dev plugins

<Aside type="caution">
The Vite and Next.js dev plugins don't read `.rep.yaml`, so they check the whole value. With their `strict` option on, a flag list longer than 64 characters can still throw in development even though the gateway accepts it. Leave `strict` off in the dev plugins, or keep the local list short.
</Aside>

## Flipping a flag

You flip a flag by changing the environment, not the image:

- **Restart:** set `REP_PUBLIC_FEATURE_FLAGS` and restart the container or roll the deployment.
- **Hot reload:** with `--hot-reload` and `--env-file`, the gateway picks up edits to that file without a restart; it does not see changes to its own process environment. Pages using `rep.onChange('FEATURE_FLAGS', …)` or a framework adapter update in place. See [Hot Reload](/concepts/hot-reload/), the [Kubernetes recipe](/deployment/kubernetes/), and the end-to-end [Next.js example](/examples/nextjs-csr-embedded/).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 File edits need a trigger

The guide says --hot-reload with --env-file picks up file edits, but the default signal mode does not watch the file. Readers following these instructions will keep serving stale flags until they send SIGHUP. Explain that trigger or show a file-detection mode with its required options.

Artifacts

Authored live gateway reproduction script

  • The script builds and launches the gateway, edits the env file, requests the injected payload, and sends a control SIGHUP; it contains the exact test commands.

Live gateway run and observed output

  • Running the authored script exited successfully and recorded the launch arguments, unchanged value after the edit, changed value after SIGHUP, and gateway logs; file editing alone did not reload the flag.

Gateway response before the env-file edit

  • An HTTP request to the running gateway returned the initial injected flag value `landing-page`; this establishes the comparison baseline.

Gateway response after the env-file edit without SIGHUP

  • An HTTP request three seconds after editing the file still returned `landing-page`; the documented invocation did not pick up the edit.

Gateway response after sending SIGHUP

  • An HTTP request after SIGHUP returned `landing-page,dark-mode`; the edited file was readable when reload was triggered.

View artifacts

T-Rex Ran code and verified through T-Rex


## What this is not

A REP flag has one value per deployment, shared by every visitor. That makes it a good fit for environment differences, kill switches and staged launches. It cannot do:

- per-user or per-account targeting;
- percentage or gradual rollouts;
- A/B tests or experiments;
- an audit trail or a UI for non-engineers.

If you need any of those, use a dedicated feature-flag service. REP can still deliver that service's public client key as a PUBLIC variable.
2 changes: 1 addition & 1 deletion docs/src/content/docs/guides/manifest.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ settings:
| `url` | Must be a valid URL |
| `number` | Must parse as a number |
| `boolean` | Must be `true`, `false`, `1`, or `0` |
| `csv` | Comma-separated values |
| `csv` | Comma-separated values. [Guardrails](/concepts/variable-classification/#automatic-secret-detection-guardrails) scan each element separately. See [Feature Flags](/guides/feature-flags/) |
| `json` | Must be valid JSON |
| `enum` | Must match one of the `values` array entries |

Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/plugins/next.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Only needed if you read `REP_SENSITIVE_*` variables with `rep.getSecure()` / `us
- **Server Component only.** `RepScript` reads the filesystem (`env`) and generates keys server-side; it cannot be used in a Client Component. Consume the injected payload from Client Components via `rep.get()` / `useRep()` as usual — the SDK reads the DOM, not React context.
- **Script content is Go-escaped** (`<`, `>`, `&` → `<`, `>`, `&`) before being written via `dangerouslySetInnerHTML`, preventing `</script>` breakout from untrusted env values. The tag still carries `type="application/json"`, so the browser never executes it.
- **Ephemeral keys are process-scoped**, held in a module-level singleton (`getOrCreateKeys()`) so the same key survives across route handler invocations within one `next dev` process.
- **Guardrails scan every `REP_PUBLIC_*` value whole**, including `csv` values the gateway scans by element ([details](/guides/feature-flags/#dev-plugins)).
- **Byte-identical payload** to the Go gateway and the Vite plugin (sorted keys, same HMAC + SRI format) — `rep.verify()` and `rep.meta()` behave the same regardless of which one produced the payload.

## App Router only
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/plugins/vite.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ repPlugin({
- **Injects `<script id="__rep__">`** into every HTML response via `transformIndexHtml`, using the same JSON shape, HMAC integrity token, and SRI hash format as the Go gateway. `rep.get()`, `rep.getSecure()`, and `rep.verify()` behave identically whether the payload came from the plugin or the gateway.
- **Watches the env file.** Changes to the file at `env` trigger a full page reload (`server.ws.send({ type: 'full-reload' })`) — no restart needed.
- **Serves `GET /rep/session-key`** as dev middleware, so `rep.getSecure()` works without a separate gateway process. Unlike the gateway's endpoint, this dev version has no rate limiting or single-use enforcement — it's not meant to run in production.
- **Runs guardrails** (entropy + known secret-format scanning) on every `REP_PUBLIC_*` value at each rebuild, logging warnings through the Vite server logger.
- **Runs guardrails** (entropy + known secret-format scanning) on every `REP_PUBLIC_*` value at each rebuild, logging warnings through the Vite server logger. Unlike the gateway, it [scans `csv` values whole](/guides/feature-flags/#dev-plugins).
- **Ephemeral keys per dev-server start.** A fresh AES key and HMAC secret are generated when the dev server boots and again on every env-file change — matching the gateway's "new keys on every restart" behavior.

<Aside type="caution">
Expand Down
1 change: 1 addition & 0 deletions docs/src/sidebar.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const sidebar = [
label: 'Guides',
items: [
{ label: 'Manifest File', slug: 'guides/manifest' },
{ label: 'Feature Flags', slug: 'guides/feature-flags' },
{ label: 'Testing', slug: 'guides/testing' },
{
label: 'Migration',
Expand Down
1 change: 1 addition & 0 deletions gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ This implementation targets **REP-RFC-0001 v0.2.0**. See the [conformance checkl

- **Manifest defaults are now injected.** An optional variable that declares `default:` and is unset in every tier is now served with that value, in its declared tier. An app-side fallback such as `rep.get('X') || ''` can go.
- **A default that fails its own type is now a startup error.** Every optional default is checked against its `type` and `pattern` at startup, even while the environment overrides it. For example, `type: url` with `default: ""` used to be ignored and now stops the gateway with a manifest validation error such as `default: variable "X" must be a valid URL`. Fix the default, or remove it.
- **`csv` variables are scanned element by element.** For a variable the manifest declares `type: csv`, the guardrails check each comma-separated element instead of the joined string, so a long list of short tokens no longer fails `--strict`. A long or secret-shaped element is still flagged by position (`csv element N`, log attribute `csv_element`), and a known key format anywhere in the list is now caught. The dev plugins [still scan whole values](https://rep-protocol.dev/guides/feature-flags/#dev-plugins).
- The startup summary gains `defaulted_vars`, and each default is logged as `rep.manifest.default_applied` (name and tier, never the value). `public_vars`, `sensitive_vars`, `server_vars` and `/rep/health` counts include defaults.

## License
Expand Down
108 changes: 60 additions & 48 deletions gateway/internal/guardrails/guardrails.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"strings"

"github.com/ruachtech/rep/gateway/internal/config"
"github.com/ruachtech/rep/gateway/internal/manifest"
)

// Warning represents a guardrail detection event.
Expand Down Expand Up @@ -61,64 +62,75 @@ var knownSecretPrefixes = []struct {
//
// Per REP-RFC-0001 §3.3, the gateway MUST scan and MUST log warnings.
// If strict mode is enabled, the caller should treat warnings as errors.
func Scan(vars *config.ClassifiedVars, logger *slog.Logger) *Result {
//
// m is the loaded manifest, or nil. A variable it declares as type csv is
// scanned element by element (split on commas, trimmed), with every
// heuristic applied to every element (§3.3).
func Scan(vars *config.ClassifiedVars, m *manifest.Manifest, logger *slog.Logger) *Result {
result := &Result{}

for _, v := range vars.Public {
// Check known secret formats.
for _, kp := range knownSecretPrefixes {
if strings.HasPrefix(v.Value, kp.prefix) {
w := Warning{
VariableName: v.Name,
OriginalKey: v.OriginalKey,
DetectionType: "known_format",
Message: fmt.Sprintf("value matches known %s format (prefix: %s)", kp.service, kp.prefix),
}
result.Warnings = append(result.Warnings, w)
logger.Warn("rep.guardrail.warning",
"variable_name", v.Name,
"detection_type", "known_format",
"detail", w.Message,
)
break // One match is enough per variable.
}
if !isCSV(m, v.Name) {
result.scanValue(v, v.Value, 0, logger)
continue
}
for i, elem := range strings.Split(v.Value, ",") {
result.scanValue(v, strings.TrimSpace(elem), i+1, logger)
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

return result
}

// isCSV reports whether the manifest declares name with type csv.
func isCSV(m *manifest.Manifest, name string) bool {
if m == nil {
return false
}
d := m.Variables[name]
return d != nil && d.Type == "csv"
}

// Check Shannon entropy.
entropy := shannonEntropy(v.Value)
if entropy > 4.5 && len(v.Value) > 16 {
w := Warning{
VariableName: v.Name,
OriginalKey: v.OriginalKey,
DetectionType: "high_entropy",
Message: fmt.Sprintf("value has high entropy (%.2f bits/char) — may be a secret", entropy),
}
result.Warnings = append(result.Warnings, w)
logger.Warn("rep.guardrail.warning",
"variable_name", v.Name,
"detection_type", "high_entropy",
"entropy", fmt.Sprintf("%.2f", entropy),
)
// scanValue runs every heuristic on value, which is the whole of v's value
// when element is 0 and its element'th csv element (1-based) otherwise.
func (r *Result) scanValue(v config.Variable, value string, element int, logger *slog.Logger) {
warn := func(detectionType, message string, extra ...any) {
args := append([]any{"variable_name", v.Name, "detection_type", detectionType}, extra...)
if element > 0 {
message = fmt.Sprintf("csv element %d: %s", element, message)
args = append(args, "csv_element", element)
}
r.Warnings = append(r.Warnings, Warning{
VariableName: v.Name,
OriginalKey: v.OriginalKey,
DetectionType: detectionType,
Message: message,
})
logger.Warn("rep.guardrail.warning", args...)
}

// Check length anomaly.
if len(v.Value) > 64 && !strings.Contains(v.Value, " ") && !strings.HasPrefix(v.Value, "http") {
w := Warning{
VariableName: v.Name,
OriginalKey: v.OriginalKey,
DetectionType: "length_anomaly",
Message: fmt.Sprintf("value is %d chars with no spaces and no URL prefix — may be an encoded secret", len(v.Value)),
}
result.Warnings = append(result.Warnings, w)
logger.Warn("rep.guardrail.warning",
"variable_name", v.Name,
"detection_type", "length_anomaly",
"length", len(v.Value),
)
// Check known secret formats.
for _, kp := range knownSecretPrefixes {
if strings.HasPrefix(value, kp.prefix) {
msg := fmt.Sprintf("value matches known %s format (prefix: %s)", kp.service, kp.prefix)
warn("known_format", msg, "detail", msg)
break // One match is enough per value.
}
}

return result
// Check Shannon entropy.
if entropy := shannonEntropy(value); entropy > 4.5 && len(value) > 16 {
warn("high_entropy",
fmt.Sprintf("value has high entropy (%.2f bits/char) — may be a secret", entropy),
"entropy", fmt.Sprintf("%.2f", entropy))
}

// Check length anomaly.
if len(value) > 64 && !strings.Contains(value, " ") && !strings.HasPrefix(value, "http") {
warn("length_anomaly",
fmt.Sprintf("value is %d chars with no spaces and no URL prefix — may be an encoded secret", len(value)),
"length", len(value))
}
}

// shannonEntropy calculates the Shannon entropy (bits per character) of a string.
Expand Down
Loading
Loading