featuresteward

module
v0.0.0-...-12a2a41 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 30, 2026 License: MIT

README

FeatureSteward

A self-hosted feature-flag and config service. Toggle features safely, roll out gradually, and know who's accountable for every flag.

status Go TypeScript


Why this exists

Shipping code and releasing features shouldn't be the same event. Feature flags let teams deploy code "dark," then turn it on for a few users, a percentage of traffic, or everyone, without redeploying.

Most teams either pay for a hosted service or hack flags into config files with no history of who changed what, and no clear owner once a flag goes stale. FeatureSteward is a small, self-hosted alternative built around four ideas:

  • Every flag has a steward: A named person accountable for it, borrowed from the Feature Steward role in FaST Agile.
  • Safe by default: Production changes require approval from a second person.
  • Accountable: Every change is recorded in an append-only audit log.
  • Fast to evaluate: Flag checks are cached and served from a lightweight API.

Features

  • Boolean flags per environment (dev, staging, prod)
  • Percentage rollouts (deterministic per user)
  • Targeting rules (user IDs, groups)
  • A steward (owner) on every flag
  • Role-based access control (viewer / editor / approver / admin)
  • Approval workflow for production changes, routed to the flag's steward
  • Stale-flag detection with steward notifications
  • Append-only audit log (tamper-evident hash chain as a stretch goal)
  • Redis-backed evaluation cache
  • Rate-limited evaluation endpoint
  • Web dashboard
  • stew command-line tool
  • VS Code extension (hover status and steward, autocomplete flag keys, stale-flag finder)

Architecture

flowchart LR
    A[Web dashboard<br/>TypeScript] -->|REST| B[FeatureSteward API<br/>Go]
    G[stew CLI] -->|REST| B
    E[VS Code extension] -->|REST| B
    F[Your app / SDK] -->|evaluate| B
    B --> C[(PostgreSQL<br/>flags, users, audit)]
    B --> D[(Redis<br/>eval cache, rate limits)]

Tech stack

Layer Choice
Backend Go (net/http)
CLI Go
Database PostgreSQL + SQL migrations
Cache Redis
Frontend TypeScript, HTML, CSS
Infra Docker, Docker Compose, GitHub Actions

Quick start

Prerequisites: Docker, and Go 1.27 or later for the make commands.

git clone https://github.com/Melmonster13/featuresteward.git
cd featuresteward
cp .env.example .env
docker compose up -d --build
make migrate
make admin HANDLE=yourname   # prints an API token once; save it

Open http://localhost:8080 and sign in with that token. The API is at http://localhost:8080/api/v1.

To work on the dashboard with live reload, run make web-dev (needs Node.js 24) and open http://localhost:3000. It forwards API calls to the server on port 8080.


The dashboard

Sign in with an API token; the dashboard swaps it for a 12-hour session.

  • Flags: every flag's state per environment and its steward, with search and filters for environment, steward (yours, or none), and stale flags. Stale flags are tagged, and stewards see how many of theirs are stale.
  • Flag page: turn a flag on or off, set its rollout and targeting rules per environment, reassign the steward, mark it permanent, archive it, and read its history. A stale flag shows why and what to do about it. In prod, saving becomes Request change, except turning the flag off.
  • Reviews: change requests waiting for you, your own, and recently closed ones, with the current and requested settings side by side.
  • Your tokens: create tokens for the CLI and scripts, and revoke them.
  • Admin pages: add users (with a first sign-in token to send them), change roles, disable users, manage SDK keys and environments.

Controls you can't use are disabled with the reason shown, and the server enforces the same rules. New tokens and SDK keys are shown once.


The stew CLI

Install (needs Go 1.27.1 or later), or run make stew to build bin/stew from a checkout:

go install github.com/Melmonster13/featuresteward/cmd/stew@latest

Log in with an API token. stew reads it from stdin, not a flag, so it stays out of your shell history:

stew login --url http://localhost:8080   # paste the token at the prompt
stew whoami

The token is saved to ~/.config/stew/config.json (mode 0600). stew logout revokes it on the server and deletes the file. In CI, set STEW_URL and STEW_TOKEN instead. stew refuses to send a token over plain http except to localhost.

Common commands:

stew list --env dev                        # flags in an environment
stew list --steward none                   # flags with no active steward
stew status new-checkout                   # state per environment, rules, steward
stew create new-checkout --name "New checkout"
stew rollout new-checkout staging 25       # 25% of users
stew toggle new-checkout staging on
stew steward new-checkout @sam             # hand the flag to another steward
stew archive new-checkout --yes            # admins only

stew rollout new-checkout prod 25 --reason "launch to a quarter"   # files a change request
stew requests                              # pending requests, and whether you can review them
stew approve 12 --comment "ship it"        # or: stew reject 12, stew cancel 12
stew toggle new-checkout prod off          # the kill switch applies immediately

stew stale --steward me                    # your flags that look safe to remove
stew permanent ops-maintenance "ops kill switch"   # never report it stale (or: --clear)

toggle and rollout change one setting and keep the rest, including targeting rules. Every change carries an Idempotency-Key, so stew retries network errors and 502/503/504 responses without applying a change twice. See Production approvals for how prod changes work.

For scripts, most commands take --json, and exit codes tell failures apart:

Code Meaning
0 Success
1 Other error
2 Bad usage
3 Not logged in, or not allowed
4 Flag or environment not found

The VS Code extension

The extension in extensions/vscode brings flags into the editor:

  • Hover a flag key in any quoted string to see its state per environment, its steward, and whether it's stale, with a link to the dashboard.
  • Autocomplete flag keys after typing a quote, or with Ctrl+Space inside the quotes.
  • Stale Flags in the Explorer lists flags that look safe to remove, with each place your code uses them.

Run FeatureSteward: Sign In and paste an API token; a viewer's token is enough, since the extension only reads. The token stays in VS Code's secret storage, and only your user settings can set the server URL, so a cloned repository can't redirect it. See the extension's README for details.

Build and install it locally:

cd extensions/vscode
npm ci --ignore-scripts
npm run package                                   # builds featuresteward-<version>.vsix
code --install-extension featuresteward-0.1.0.vsix

CI also builds the .vsix on every push; download it from the run's Artifacts.

Publish it (only the maintainer does this, and the tokens never go in the repo):

  1. Create a publisher at https://marketplace.visualstudio.com/manage whose ID matches publisher in extensions/vscode/package.json.
  2. Create an Azure DevOps personal access token with the Marketplace (Manage) scope, then run npx vsce login <publisher> and paste it at the prompt.
  3. Run npx vsce publish from extensions/vscode.
  4. For Open VSX (VSCodium, Cursor, and others), create a token at https://open-vsx.org, then run read -rs OVSX_PAT && export OVSX_PAT (paste it; nothing is shown), npx ovsx create-namespace <publisher> once, and npx ovsx publish featuresteward-0.1.0.vsix.

Usage

Authentication

Every API request sends Authorization: Bearer <credential>. There are two kinds:

  • API tokens (fs_…) belong to a user and carry that user's role. Changes made with one are recorded under the user's handle in the audit log.
  • SDK keys (fs_sdk_…) belong to one environment and can only call /api/v1/evaluate in that environment. Give these to your applications.

Both are shown once when created. Only a SHA-256 hash is stored.

Browser sessions: The dashboard signs in by sending an API token to POST /api/v1/session, which sets an HttpOnly, SameSite=Strict session cookie for 12 hours. The cookie is Secure everywhere except http://localhost, so the dashboard needs HTTPS in production. A session ends at logout (DELETE /api/v1/session), or when its token is revoked or its user is disabled. Requests that use the cookie to change something must come from the dashboard's own origin.

Evaluate a flag
curl -X POST http://localhost:8080/api/v1/evaluate \
  -H "Authorization: Bearer <sdk-key or token>" \
  -H "Content-Type: application/json" \
  -d '{"flag": "new-checkout", "environment": "prod", "user_id": "user-42"}'
{ "flag": "new-checkout", "enabled": true, "reason": "percentage_rollout" }
Safe retries

Send an Idempotency-Key header (any unique string, up to 255 characters) with a POST, PUT, or DELETE. If the same user retries with the same key and the same request within 24 hours, the API returns the original response with Idempotent-Replayed: true instead of applying the change again. Reusing a key for a different request returns 422.

Responses that contain a new API token or SDK key are replayed without the secret, since secrets are never stored. Revoke the replayed id and create a new one if the first response was lost.

How percentage rollouts work

Each user is assigned a stable bucket from hash(flag_key + user_id) % 100. A flag at 25% is on for buckets 0–24. The same user always gets the same result, and raising the percentage only adds users; nobody who already has the feature loses it.


Stewards and roles

Steward: Every flag has one. The steward is the point of contact for that flag, reviews production change requests for it, and is notified when it goes stale. Stewardship is per flag, not a permission level. A new flag's steward is its creator unless another is named; stewards must be active editors or above. Admins can reassign any flag, and a steward can hand their own flag to someone else. GET /api/v1/flags?steward=none lists flags with no steward or a disabled one.

Roles control what each user can do across the system:

Role Can do
Viewer See flags, stewards, and audit history
Editor Create and change flags in dev / staging, request changes in prod, and turn prod flags off
Approver Approve or reject prod change requests
Admin Manage users, roles, environments, and steward assignments

Roles are cumulative: each includes the permissions of the roles above it in the table. Permissions are enforced on the server for every request, never only in the UI. Self-approval is blocked.

prod is a protected environment; admins can protect others. Only admins can archive flags.

Production approvals

A change to a protected environment is a change request that someone else approves:

  1. An editor requests the new settings, with an optional reason: Request change in the dashboard, stew rollout … prod, or POST /api/v1/flags/{key}/environments/prod/requests.
  2. The flag's steward, an approver, or an admin approves or rejects it, optionally with a comment. The requester can't review their own request, but can cancel it.
  3. Approving applies the change, but only if prod still has the settings the request was based on. If someone changed it in the meantime, the approval fails and the request needs to be made again.

Requests that nobody reviews expire after 7 days, and only one request per flag and environment can be pending at a time.

Two changes skip approval:

  • The kill switch: anyone who can edit can turn a flag off in prod right away, as long as nothing else changes.
  • Emergency changes: an admin can apply any change directly by giving a reason ("reason" in the API, --emergency in stew). The reason is kept in the audit log.

Every request, review, and change is recorded in the flag's history.


Stale flags

A flag that has done its job becomes dead code. FeatureSteward reports a flag as stale, with a suggestion, when it has been one of these for STALE_AFTER_DAYS (30 by default):

Stale as Meaning Suggestion
Unused Nothing has evaluated it in any environment Check the code no longer uses it, then archive it
Always on Every environment serves on to everyone, unchanged Remove the flag and keep the new code
Always off Every environment serves off to everyone, unchanged Remove the flag and the code behind it
Settled Unchanged, serving one value to everyone in each environment, but on in some and off in others Decide on one, then remove the flag

"Everyone" means the flag is off, or on at 0% or 100% with no rules that serve the other value. Protected environments decide between always on and always off when they agree. Flags with a pending change request, archived flags, and permanent flags are never stale.

Where it shows up: the dashboard's flag list (tag and filter) and flag page, stew stale, stew status, and GET /api/v1/flags?stale=true. Every flag in the API has a stale field (null when it isn't) and its last change and evaluation per environment.

Permanent flags: some flags are meant to last, like an operations kill switch. The steward or an admin can mark one permanent with a reason (the flag page, stew permanent, or PUT /api/v1/flags/{key}/permanent), and it's never reported stale. The mark and its removal are in the flag's history.

Daily digest: with STALE_WEBHOOK_URL set, the server posts stale flags once a day, grouped by steward, with links to the dashboard when PUBLIC_URL is set. It lists flag keys and steward handles only, and repeats a flag at most once a week. With several servers, one sends it.

Evaluations are counted in memory and saved to Postgres every minute, so recording them doesn't slow down /evaluate. A server that stops abruptly can lose up to a minute of this, which can only make a flag look stale a minute early.


Configuration

Variable Description Default
DATABASE_URL PostgreSQL connection string —
REDIS_URL Redis connection string. Optional: without it, or while Redis is down, evaluations aren't cached or rate limited. /healthz reports its status. —
PORT API port 8080
CACHE_TTL_SECONDS How long evaluations and SDK key lookups stay cached. Changes clear it right away; this only bounds how stale it can get if Redis misses a change. 0 turns the cache off. 30
RATE_LIMIT_PER_MIN Evaluations per minute for each SDK key or user. Over it, /evaluate returns 429 with Retry-After; every response has RateLimit-* headers. 0 turns it off. Needs Redis. 600
STALE_AFTER_DAYS Days before a flag counts as stale: not evaluated anywhere, or serving one value to everyone, unchanged, for this long. See Stale flags. 30
STALE_WEBHOOK_URL Optional webhook for a daily digest of stale flags, grouped by steward, as {"text": ...} (Slack-compatible). It's a secret: it's never logged. Must be https, except to localhost. —
PUBLIC_URL The dashboard's URL, used for links in the digest. —

Project structure

cmd/featuresteward/    API server entry point
cmd/stew/              CLI
cmd/evalload/          evaluation load test
internal/flag/         flags, environments, and change requests
internal/eval/         rule matching and rollout hashing
internal/audit/        append-only audit events
internal/auth/         users, tokens, SDK keys, sessions, and roles
internal/idempotency/  Idempotency-Key storage
internal/store/        PostgreSQL implementations
internal/cache/        Redis cache for flag evaluation and SDK keys
internal/ratelimit/    per-client rate limits in Redis
internal/redisguard/   fallback when Redis is down
internal/httpapi/      routes, handlers, and middleware
internal/client/       Go API client used by stew
migrations/            versioned SQL (up/down)
web/                   dashboard (TypeScript), embedded in the server
extensions/vscode/     VS Code extension (TypeScript)

Design decisions

  • Postgres is the source of truth; Redis is only a cache. If Redis goes down, evaluation reads the database and rate limits are skipped. After a Redis failure the server leaves Redis alone for 5 seconds, so an outage doesn't add a timeout to every request.
  • Changes clear the cache right after they're saved, and again a second later in case a read raced the change, so the kill switch and approvals take effect on the next evaluation. The TTL only bounds staleness when Redis misses a change, for example during an outage.
  • SDK key lookups are cached too, and revoking any key clears them all, right away and again a second later, so a revoked key stops working on its next request. If Redis can't be cleared during a revoke, the server clears it before using it again; another server sharing that Redis could accept the revoked key for at most CACHE_TTL_SECONDS. Failed lookups are never cached, so random keys can't fill Redis, and Redis stores a hash of each key, never the key.
  • Rate limits count per SDK key or user, not per IP address, so they work the same behind a proxy. Keys in Redis hold a hash of the SDK key, never the key itself.
  • The audit log is append-only. Events are never updated or deleted.
  • The CLI, dashboard, and extension are all API clients. Every rule lives in the server, so no client can bypass approvals.
  • Storage sits behind interfaces, so business logic is tested against in-memory fakes.
  • State-changing requests accept an idempotency key, so retries can't apply a change twice.
  • An approval applies only to the settings it was requested against. Approving checks and changes prod in one transaction, so an approval can't overwrite a newer change.

Testing

make test           # unit tests
make test-int       # integration tests (Postgres and Redis from docker compose)
cd web && npm test  # dashboard tests
cd extensions/vscode && npm test   # extension tests

CI runs go vet, unit and integration tests against real Postgres and Redis, govulncheck and npm audit, a secret scan, the dashboard's typecheck, tests, and build, a Docker build, the VS Code extension's tests and package, and a stew build for Linux, macOS, and Windows on every pull request.

Performance

cmd/evalload sends evaluations as fast as it can and reports throughput and latency. The SDK key comes from an environment variable, so it stays out of your shell history:

EVALLOAD_KEY=fs_sdk_... go run ./cmd/evalload -flag new-checkout -c 32 -d 20s

On a MacBook Pro with Postgres and Redis in Docker, 32 concurrent clients, rate limiting off, and a flag with a rollout and one rule:

Evaluations/s p50 p99 Database transactions per evaluation
Cache off 9,656 3.3 ms 5.2 ms 2
Cache on 19,121 1.6 ms 2.9 ms 0

With the cache on, an evaluation reads neither the flag nor the SDK key from Postgres. These numbers come from one laptop running everything, so treat them as a comparison, not a capacity estimate.


Roadmap

  1. ✅ Core flags + evaluation API
  2. ✅ Auth, RBAC, and audit log
  3. ✅ Stewards + stew CLI
  4. ✅ Dashboard
  5. ✅ Approvals for production
  6. ✅ Redis cache + rate limiting
  7. ✅ Stale-flag detection
  8. ✅ VS Code extension
  9. Stretch: OpenFeature-compatible provider

Author

Built by Mel · MelStackBox · GitHub

License

MIT

Directories

Path Synopsis
cmd
evalload command
Command evalload sends flag evaluations to a FeatureSteward server as fast as it can and reports throughput and latency.
Command evalload sends flag evaluations to a FeatureSteward server as fast as it can and reports throughput and latency.
featuresteward command
stew command
Command stew is the FeatureSteward command-line tool.
Command stew is the FeatureSteward command-line tool.
internal
audit
Package audit defines entries in the append-only audit log.
Package audit defines entries in the append-only audit log.
auth
Package auth holds users, roles, API tokens, and their storage contract.
Package auth holds users, roles, API tokens, and their storage contract.
auth/authtest
Package authtest provides an in-memory auth.Store and a contract test suite that every auth.Store implementation must pass.
Package authtest provides an in-memory auth.Store and a contract test suite that every auth.Store implementation must pass.
cache
Package cache keeps flag evaluation settings in Redis in front of a flag.Store.
Package cache keeps flag evaluation settings in Redis in front of a flag.Store.
client
Package client is a Go client for the FeatureSteward REST API.
Package client is a Go client for the FeatureSteward REST API.
digest
Package digest tells stewards about their stale flags once a day, by posting a Slack-compatible message to a webhook.
Package digest tells stewards about their stale flags once a day, by posting a Slack-compatible message to a webhook.
errs
Package errs holds the sentinel errors shared by domain packages and mapped to HTTP statuses by the API.
Package errs holds the sentinel errors shared by domain packages and mapped to HTTP statuses by the API.
eval
Package eval decides whether a flag is on for a given user.
Package eval decides whether a flag is on for a given user.
flag
Package flag holds the flag domain types and the storage contract.
Package flag holds the flag domain types and the storage contract.
flag/flagtest
Package flagtest provides an in-memory flag.Store and a contract test suite that every flag.Store implementation must pass.
Package flagtest provides an in-memory flag.Store and a contract test suite that every flag.Store implementation must pass.
httpapi
Package httpapi serves the FeatureSteward REST API.
Package httpapi serves the FeatureSteward REST API.
idempotency
Package idempotency stores responses to requests sent with an Idempotency-Key header so retries can be replayed.
Package idempotency stores responses to requests sent with an Idempotency-Key header so retries can be replayed.
idempotency/idemtest
Package idemtest provides an in-memory idempotency.Store and a contract test suite that every implementation must pass.
Package idemtest provides an in-memory idempotency.Store and a contract test suite that every implementation must pass.
ratelimit
Package ratelimit counts requests per client in fixed one-minute windows in Redis.
Package ratelimit counts requests per client in fixed one-minute windows in Redis.
redisguard
Package redisguard helps code that treats Redis as optional: after a failure it skips Redis for a cooldown, so an outage doesn't add a timeout to every request, and it logs failures at most once a minute.
Package redisguard helps code that treats Redis as optional: after a failure it skips Redis for a cooldown, so an outage doesn't add a timeout to every request, and it logs failures at most once a minute.
store
Package store implements flag.Store on PostgreSQL.
Package store implements flag.Store on PostgreSQL.
usage
Package usage remembers when flags are evaluated and writes it to the store periodically, so evaluating a flag never writes to the database.
Package usage remembers when flags are evaluated and writes it to the store periodically, so evaluating a flag never writes to the database.
Package web serves the dashboard's built files (npm run build in web/).
Package web serves the dashboard's built files (npm run build in web/).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL