@strav/flag
Feature flags for Strav 1.x. Define
flags, scope them per user / team / tenant, persist resolutions in-memory or in
Postgres, and gate routes with an HTTP middleware. Mirrors the shape of
@strav/cache — kernel-free core in the root, drivers and integrations under
subpaths.
Install
bun add @strav/flag
Quick start
import { FlagManager, FlagProvider } from '@strav/flag'
new FlagProvider({
define(flags) {
flags.define('beta-ui', (scope) => scope.startsWith('User:1'))
flags.define('upload-limit', 25)
},
})
const flags = app.resolve(FlagManager)
if (await flags.active('beta-ui', user)) { ... }
if (await flags.for(team).active('analytics')) { ... }
const limit = await flags.value('upload-limit')
Drivers
| (root) | MemoryFlagStore | dev / tests / single-process apps |
@strav/flag/postgres | PostgresFlagStore | multi-node deployments (cross-process) |
Swap providers — both register under the same FlagManager token:
import { PostgresFlagProvider, applyFlagMigration } from '@strav/flag/postgres'
await applyFlagMigration(db)
providers: [
new PostgresFlagProvider({
define(flags) {
flags.define('beta-ui', (scope) => scope.startsWith('User:'))
},
}),
]
HTTP guard
import { ensureFeature } from '@strav/flag/http'
router.get('/beta', ensureFeature('beta-ui'), betaHandler)
router.group(
{ middleware: [authMiddleware(), ensureFeature('analytics')] },
(r) => r.get('/insights', insights),
)
Default scope is ctx.auth?.user. Pass scopeFrom for team / tenant / custom
subjects.
CLI
bun strav flag:list
bun strav flag:activate beta-ui
bun strav flag:activate beta-ui '"control"' --scope User:42
bun strav flag:deactivate beta-ui --scope User:42
bun strav flag:purge beta-ui
Register the commands provider alongside the flag provider:
import { FlagConsoleProvider } from '@strav/flag/console'
providers: [
new FlagProvider({ }),
new FlagConsoleProvider(),
]
Strict scopes
export default { strictScopes: true }
With strictScopes: true, reading a flag without a scope throws
MissingScopeError instead of silently evaluating the global value. Catches the
common bug where middleware forgets to pass ctx.auth.user. Writes keep the
loose semantics — activateForEveryone() makes a global write explicit.
Audit hook
activate / deactivate accept an optional actor: { type, id } that flows
into the flag:updated event payload alongside previous (the prior stored
value). Subscribe via EventBus to wire an audit log without coupling
@strav/flag to @strav/audit:
app.resolve(EventBus).on('flag:updated', (e) => {
if (!e.actor) return
auditLog.record({
actor: e.actor,
on: 'feature_flag',
feature: e.feature,
scope: e.scope,
diff: { from: e.previous, to: e.value },
})
})
License
MIT