@strav/flag 0.4.31 → 1.0.0-alpha.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,33 +1,57 @@
1
- import { ServiceProvider } from '@strav/kernel'
2
- import type { Application } from '@strav/kernel'
3
- import FlagManager from './flag_manager.ts'
1
+ /**
2
+ * `FlagProvider` registers `FlagManager` under its own token, backed
3
+ * by the in-process `MemoryFlagStore` by default. Apps that need
4
+ * cross-process persistence swap in `PostgresFlagProvider` from
5
+ * `@strav/flag/postgres` — both register under the same `FlagManager`
6
+ * token, so consumers don't change their inject.
7
+ *
8
+ * `define` lets callers register feature resolvers up-front in the
9
+ * same place they wire the provider:
10
+ *
11
+ * new FlagProvider({
12
+ * define(flags) {
13
+ * flags.define('beta-ui', (scope) => scope.startsWith('User:'))
14
+ * },
15
+ * })
16
+ *
17
+ * Resolvers can also be added later (e.g. from another provider's
18
+ * `boot()`) via `app.resolve(FlagManager).define(...)`.
19
+ */
20
+
21
+ import { type Application, ConfigRepository, EventBus, ServiceProvider } from '@strav/kernel'
22
+ import { MemoryFlagStore } from './drivers/memory/memory_flag_store.ts'
23
+ import { FlagManager } from './flag_manager.ts'
24
+ import type { FlagConfig } from './types.ts'
4
25
 
5
26
  export interface FlagProviderOptions {
6
- /** Auto-create the features table. Default: `true` */
7
- ensureTables?: boolean
27
+ /**
28
+ * Hook invoked at boot, after the manager is created. Use it to
29
+ * register flag resolvers.
30
+ */
31
+ define?: (flags: FlagManager) => void | Promise<void>
8
32
  }
9
33
 
10
- export default class FlagProvider extends ServiceProvider {
11
- readonly name = 'flag'
12
- override readonly dependencies = ['config', 'database']
34
+ export class FlagProvider extends ServiceProvider {
35
+ override readonly name = 'flag'
36
+ override readonly dependencies = ['config']
13
37
 
14
- constructor(private options?: FlagProviderOptions) {
38
+ constructor(private readonly options: FlagProviderOptions = {}) {
15
39
  super()
16
40
  }
17
41
 
18
42
  override register(app: Application): void {
19
- app.singleton(FlagManager)
43
+ app.singleton(FlagManager, (c) => {
44
+ const cfg = (c.resolve(ConfigRepository).get('flag') as FlagConfig | undefined) ?? {}
45
+ return new FlagManager({
46
+ store: new MemoryFlagStore(),
47
+ events: c.resolve(EventBus),
48
+ config: cfg,
49
+ })
50
+ })
20
51
  }
21
52
 
22
53
  override async boot(app: Application): Promise<void> {
23
- app.resolve(FlagManager)
24
-
25
- if (this.options?.ensureTables !== false) {
26
- await FlagManager.ensureTables()
27
- }
28
- }
29
-
30
- override shutdown(): void {
31
- FlagManager.reset()
54
+ const flags = app.resolve(FlagManager)
55
+ if (this.options.define) await this.options.define(flags)
32
56
  }
33
57
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `ensureFeature` — route guard that 403s when a flag is inactive.
3
+ *
4
+ * router.get('/beta', ensureFeature('beta-ui'), betaHandler)
5
+ *
6
+ * router.group(
7
+ * { middleware: [auth(), ensureFeature('analytics', (ctx) => ctx.auth?.user ?? null)] },
8
+ * (r) => { ... }
9
+ * )
10
+ *
11
+ * Scope source: by default the middleware reads `ctx.auth?.user` if
12
+ * the `@strav/auth` augmentation is installed and falls back to the
13
+ * global scope otherwise. Pass `scopeFrom` for per-route logic (team,
14
+ * tenant, custom subject).
15
+ *
16
+ * Returns a `Response` directly instead of throwing — the response is
17
+ * symmetrical for HTML and JSON callers (both get a 403), and the
18
+ * caller doesn't need to wire a custom exception handler.
19
+ */
20
+
21
+ import type { HttpContext, MiddlewareFn } from '@strav/http'
22
+ import { FlagManager } from '../flag_manager.ts'
23
+ import type { Scopeable } from '../types.ts'
24
+
25
+ export interface EnsureFeatureOptions {
26
+ /** Custom value when the feature is denied. Default: `{ error: 'Feature not available' }`. */
27
+ body?: unknown
28
+ /** Custom status. Default: `403`. */
29
+ status?: number
30
+ /** Where to read the scope from. Default: `ctx.auth?.user ?? null`. */
31
+ scopeFrom?: (ctx: HttpContext) => Scopeable | null | undefined
32
+ }
33
+
34
+ export function ensureFeature(feature: string, options: EnsureFeatureOptions = {}): MiddlewareFn {
35
+ return async (ctx, next) => {
36
+ const flags = ctx.container.resolve(FlagManager)
37
+ const scope = options.scopeFrom ? options.scopeFrom(ctx) : defaultScope(ctx)
38
+ const active = await flags.active(feature, scope)
39
+ if (active) return next()
40
+ return new Response(
41
+ JSON.stringify(options.body ?? { error: 'Feature not available', feature }),
42
+ {
43
+ status: options.status ?? 403,
44
+ headers: { 'Content-Type': 'application/json' },
45
+ },
46
+ )
47
+ }
48
+ }
49
+
50
+ function defaultScope(ctx: HttpContext): Scopeable | null {
51
+ // `ctx.auth` only exists when `@strav/auth` is loaded — the
52
+ // augmentation is declared there. Read defensively so this
53
+ // middleware works in apps without auth wired.
54
+ const auth = (ctx as unknown as { auth?: { user?: Scopeable | null } }).auth
55
+ return auth?.user ?? null
56
+ }
@@ -0,0 +1 @@
1
+ export { type EnsureFeatureOptions, ensureFeature } from './ensure_feature.ts'
package/src/index.ts CHANGED
@@ -1,40 +1,30 @@
1
- // Manager
2
- export { default, default as FlagManager } from './flag_manager.ts'
3
-
4
- // Provider
5
- export { default as FlagProvider } from './flag_provider.ts'
6
- export type { FlagProviderOptions } from './flag_provider.ts'
7
-
8
- // Helper
9
- export { flag } from './helpers.ts'
10
-
11
- // Store interface
1
+ // Public API of @strav/flag.
2
+ //
3
+ // Root barrel exports the manager + types + errors + the in-memory
4
+ // store and its provider. Backends and integrations ship under
5
+ // subpaths:
6
+ // - `@strav/flag/memory` (re-exports the in-memory store)
7
+ // - `@strav/flag/postgres` (Postgres backplane + migration helper)
8
+ // - `@strav/flag/http` (ensureFeature route guard)
9
+ // - `@strav/flag/console` (flag:* CLI commands)
10
+
11
+ export { MemoryFlagStore } from './drivers/memory/memory_flag_store.ts'
12
12
  export type { FeatureStore } from './feature_store.ts'
13
-
14
- // Drivers
15
- export { DatabaseDriver } from './drivers/database_driver.ts'
16
- export { ArrayDriver } from './drivers/array_driver.ts'
17
-
18
- // Scoped API
19
- export { default as PendingScopedFeature } from './pending_scope.ts'
20
-
21
- // Middleware
22
- export { ensureFeature } from './middleware/ensure_feature.ts'
23
-
24
- // Errors
25
- export { FlagError, FeatureNotDefinedError, MissingScopeError } from './errors.ts'
26
-
27
- // Types
28
- export type {
29
- FlagConfig,
30
- DriverConfig,
31
- Scopeable,
32
- ScopeKey,
33
- StoredFeature,
34
- FeatureResolver,
35
- FeatureClass,
36
- FeatureClassConstructor,
37
- FlagActor,
38
- FlagUpdatedEvent,
13
+ export { FeatureNotDefinedError, FlagError, MissingScopeError } from './flag_error.ts'
14
+ export { FlagManager, type FlagManagerOptions } from './flag_manager.ts'
15
+ export { FlagProvider, type FlagProviderOptions } from './flag_provider.ts'
16
+ export { PendingScopedFeature } from './pending_scope.ts'
17
+ export {
18
+ type FeatureClass,
19
+ type FeatureClassConstructor,
20
+ type FeatureResolver,
21
+ type FlagActor,
22
+ type FlagConfig,
23
+ type FlagDeletedEvent,
24
+ type FlagResolvedEvent,
25
+ type FlagUpdatedEvent,
26
+ GLOBAL_SCOPE,
27
+ type Scopeable,
28
+ type ScopeKey,
29
+ type StoredFeature,
39
30
  } from './types.ts'
40
- export { GLOBAL_SCOPE } from './types.ts'
@@ -1,47 +1,59 @@
1
+ /**
2
+ * `PendingScopedFeature` — the fluent return value of
3
+ * `FlagManager.for(scope)`. Lets call sites read several features for
4
+ * the same scope without repeating the scope argument.
5
+ *
6
+ * const flags = app.resolve(FlagManager).for(ctx.auth.user)
7
+ * if (await flags.active('beta-ui')) { ... }
8
+ * const limit = await flags.value('upload-limit')
9
+ */
10
+
11
+ import type { FlagManager } from './flag_manager.ts'
1
12
  import type { FlagActor, Scopeable } from './types.ts'
2
- import FlagManager from './flag_manager.ts'
3
13
 
4
- /** Fluent scoped feature check — created by `FlagManager.for(scope)`. */
5
- export default class PendingScopedFeature {
6
- constructor(private scope: Scopeable) {}
14
+ export class PendingScopedFeature {
15
+ constructor(
16
+ private readonly manager: FlagManager,
17
+ private readonly scope: Scopeable,
18
+ ) {}
7
19
 
8
20
  value(feature: string): Promise<unknown> {
9
- return FlagManager.value(feature, this.scope)
21
+ return this.manager.value(feature, this.scope)
10
22
  }
11
23
 
12
24
  active(feature: string): Promise<boolean> {
13
- return FlagManager.active(feature, this.scope)
25
+ return this.manager.active(feature, this.scope)
14
26
  }
15
27
 
16
28
  inactive(feature: string): Promise<boolean> {
17
- return FlagManager.inactive(feature, this.scope)
29
+ return this.manager.inactive(feature, this.scope)
18
30
  }
19
31
 
20
32
  when<A, I>(
21
33
  feature: string,
22
34
  onActive: (value: unknown) => A | Promise<A>,
23
- onInactive: () => I | Promise<I>
35
+ onInactive: () => I | Promise<I>,
24
36
  ): Promise<A | I> {
25
- return FlagManager.when(feature, onActive, onInactive, this.scope)
37
+ return this.manager.when(feature, onActive, onInactive, this.scope)
26
38
  }
27
39
 
28
40
  activate(feature: string, value?: unknown, actor?: FlagActor | null): Promise<void> {
29
- return FlagManager.activate(feature, value, this.scope, actor)
41
+ return this.manager.activate(feature, value, this.scope, actor)
30
42
  }
31
43
 
32
44
  deactivate(feature: string, actor?: FlagActor | null): Promise<void> {
33
- return FlagManager.deactivate(feature, this.scope, actor)
45
+ return this.manager.deactivate(feature, this.scope, actor)
34
46
  }
35
47
 
36
48
  forget(feature: string): Promise<void> {
37
- return FlagManager.forget(feature, this.scope)
49
+ return this.manager.forget(feature, this.scope)
38
50
  }
39
51
 
40
52
  values(features: string[]): Promise<Map<string, unknown>> {
41
- return FlagManager.values(features, this.scope)
53
+ return this.manager.values(features, this.scope)
42
54
  }
43
55
 
44
56
  load(features: string[]): Promise<void> {
45
- return FlagManager.load(features, [this.scope])
57
+ return this.manager.load(features, [this.scope])
46
58
  }
47
59
  }
package/src/types.ts CHANGED
@@ -1,21 +1,40 @@
1
- // ── Scope ────────────────────────────────────────────────────────────────
1
+ /**
2
+ * Public types for `@strav/flag`.
3
+ *
4
+ * Feature flags are evaluated against a *scope* — usually the
5
+ * authenticated user, sometimes a team / workspace / tenant, sometimes
6
+ * nothing at all (the global scope). The scope is serialized to a
7
+ * stable string key and used as the lookup discriminator both in the
8
+ * in-process cache and the persistent store.
9
+ */
10
+
11
+ // ─── Scope ────────────────────────────────────────────────────────────────
2
12
 
3
- /** Any object that can be used as a feature flag scope. */
13
+ /**
14
+ * Anything that can serve as a flag scope. The shape is intentionally
15
+ * loose — any object with an `id` works (`User`, `Team`, `Tenant`,
16
+ * `ApiKey`, etc.). Pass `featureScope()` to override the type prefix
17
+ * when the constructor name is unstable (minified bundles, mixins).
18
+ */
4
19
  export interface Scopeable {
5
20
  id: string | number
6
- /** Optional type discriminator. Defaults to constructor.name. */
21
+ /** Optional discriminator. Defaults to `constructor.name`. */
7
22
  featureScope?: () => string
8
23
  }
9
24
 
10
- /** Serialized scope string, e.g. 'User:42', '__global__'. */
25
+ /** Serialized scope string, e.g. `User:42`, `Team:7`, `__global__`. */
11
26
  export type ScopeKey = string
12
27
 
13
- /** The global scope sentinel. */
28
+ /** Sentinel for the global (no scope) bucket. */
14
29
  export const GLOBAL_SCOPE = '__global__'
15
30
 
16
- // ── Feature definitions ──────────────────────────────────────────────────
31
+ // ─── Feature definitions ──────────────────────────────────────────────────
17
32
 
18
- /** A closure that resolves a feature value for the given scope. */
33
+ /**
34
+ * A closure that resolves a feature value for the given scope key.
35
+ * The return value is whatever the app wants — a `boolean` for binary
36
+ * flags, a `string`/`number`/object for A-B variants or rich config.
37
+ */
19
38
  export type FeatureResolver<T = unknown> = (scope: ScopeKey) => T | Promise<T>
20
39
 
21
40
  /** A class-based feature with a `resolve` method. */
@@ -29,7 +48,7 @@ export interface FeatureClassConstructor {
29
48
  new (): FeatureClass
30
49
  }
31
50
 
32
- // ── Stored values ────────────────────────────────────────────────────────
51
+ // ─── Stored values ────────────────────────────────────────────────────────
33
52
 
34
53
  export interface StoredFeature {
35
54
  feature: string
@@ -39,53 +58,58 @@ export interface StoredFeature {
39
58
  updatedAt: Date
40
59
  }
41
60
 
42
- // ── Configuration ────────────────────────────────────────────────────────
61
+ // ─── Configuration ────────────────────────────────────────────────────────
43
62
 
44
63
  export interface FlagConfig {
45
- default: string
46
- drivers: Record<string, DriverConfig>
47
64
  /**
48
- * When true, calls to `value`/`active`/`activate`/`deactivate` that
49
- * resolve to a null/undefined scope throw `MissingScopeError` instead
50
- * of silently falling back to the global scope. Default: `false`
51
- * (backwards-compatible). Apps that derive scope from request state
52
- * (e.g., `ctx.get('user')`) should turn this on so a forgotten or
53
- * unauthenticated request raises a loud error instead of evaluating
54
- * the global flag for everyone.
65
+ * When `true`, the read path (`value` / `active` / `inactive` /
66
+ * `when` / `values` / `forget`) called without a scope throws
67
+ * `MissingScopeError` instead of silently falling back to the
68
+ * global value. Defends against the common bug where middleware
69
+ * forgets to pass `ctx.auth.user` and a per-user flag silently
70
+ * evaluates the global flag for everyone.
71
+ *
72
+ * The write path keeps loose semantics — `activate('flag')` with no
73
+ * scope still writes the global value. Callers that want explicit
74
+ * global writes should prefer `activateForEveryone()`.
75
+ *
76
+ * Default: `false` (backward-compatible). Turn on in new apps.
55
77
  */
56
- strictScopes: boolean
78
+ strictScopes?: boolean
57
79
  }
58
80
 
59
- export interface DriverConfig {
60
- driver: string
61
- [key: string]: unknown
62
- }
63
-
64
- // ── Actor ────────────────────────────────────────────────────────────────
81
+ // ─── Actor ────────────────────────────────────────────────────────────────
65
82
 
66
83
  /**
67
- * Who initiated a flag write. Optional, but recommended for accountability.
68
- * Carried through to `flag:updated` event payloads so an audit hook can
69
- * record the change. See `@strav/flag` CLAUDE.md for the recommended
70
- * audit-integration pattern.
84
+ * Who initiated a flag write. Optional but recommended for audit. The
85
+ * actor is carried through to the `flag:updated` event so an audit
86
+ * hook can record who flipped what.
71
87
  */
72
88
  export interface FlagActor {
73
89
  type: string
74
90
  id: string | number
75
91
  }
76
92
 
77
- // ── Events ───────────────────────────────────────────────────────────────
93
+ // ─── Events ───────────────────────────────────────────────────────────────
94
+
95
+ export interface FlagResolvedEvent {
96
+ feature: string
97
+ scope: ScopeKey
98
+ value: unknown
99
+ }
78
100
 
79
- /**
80
- * Payload for the `flag:updated` Emitter event. Fired when `activate()`
81
- * or `deactivate()` writes to the store.
82
- */
83
101
  export interface FlagUpdatedEvent {
84
102
  feature: string
85
103
  scope: ScopeKey
86
104
  value: unknown
87
- /** Previous stored value (if any) — undefined when the flag had no prior store entry. */
105
+ /** Previous stored value, or `undefined` if the flag had no entry. */
88
106
  previous: unknown
89
- /** Who initiated the change. `null` when the caller did not provide an actor. */
107
+ /** Caller-supplied actor, `null` when not provided. */
90
108
  actor: FlagActor | null
91
109
  }
110
+
111
+ export interface FlagDeletedEvent {
112
+ feature: string
113
+ /** Specific scope, or `*` for purge / purgeAll. */
114
+ scope: ScopeKey | '*'
115
+ }
@@ -1,99 +0,0 @@
1
- import type { Command } from 'commander'
2
- import chalk from 'chalk'
3
- import { bootstrap, shutdown } from '@strav/cli'
4
- import FlagManager from '../flag_manager.ts'
5
-
6
- export function register(program: Command): void {
7
- program
8
- .command('flag:setup')
9
- .description('Create the feature flag storage table')
10
- .action(async () => {
11
- let db
12
- try {
13
- const { db: database, config } = await bootstrap()
14
- db = database
15
-
16
- new FlagManager(db, config)
17
-
18
- console.log(chalk.dim('Creating features table...'))
19
- await FlagManager.ensureTables()
20
- console.log(chalk.green('Features table created successfully.'))
21
- } catch (err) {
22
- console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
23
- process.exit(1)
24
- } finally {
25
- if (db) await shutdown(db)
26
- }
27
- })
28
-
29
- program
30
- .command('flag:purge [feature]')
31
- .description('Purge stored feature flag values')
32
- .option('--all', 'Purge all features')
33
- .action(async (feature?: string, options?: { all?: boolean }) => {
34
- let db
35
- try {
36
- const { db: database, config } = await bootstrap()
37
- db = database
38
-
39
- new FlagManager(db, config)
40
-
41
- if (options?.all || !feature) {
42
- console.log(chalk.dim('Purging all feature flags...'))
43
- await FlagManager.purgeAll()
44
- console.log(chalk.green('All feature flags purged.'))
45
- } else {
46
- console.log(chalk.dim(`Purging feature "${feature}"...`))
47
- await FlagManager.purge(feature)
48
- console.log(chalk.green(`Feature "${feature}" purged.`))
49
- }
50
- } catch (err) {
51
- console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
52
- process.exit(1)
53
- } finally {
54
- if (db) await shutdown(db)
55
- }
56
- })
57
-
58
- program
59
- .command('flag:list')
60
- .description('List all stored feature flags')
61
- .action(async () => {
62
- let db
63
- try {
64
- const { db: database, config } = await bootstrap()
65
- db = database
66
-
67
- new FlagManager(db, config)
68
-
69
- const names = await FlagManager.stored()
70
-
71
- if (names.length === 0) {
72
- console.log(chalk.dim('No stored feature flags.'))
73
- return
74
- }
75
-
76
- console.log(chalk.bold(`Stored feature flags (${names.length}):\n`))
77
- for (const name of names) {
78
- const records = await FlagManager.store().allFor(name)
79
- console.log(
80
- ` ${chalk.cyan(name)} ${chalk.dim(`(${records.length} scope${records.length === 1 ? '' : 's'})`)}`
81
- )
82
- for (const r of records) {
83
- const val =
84
- typeof r.value === 'boolean'
85
- ? r.value
86
- ? chalk.green('active')
87
- : chalk.red('inactive')
88
- : chalk.yellow(JSON.stringify(r.value))
89
- console.log(` ${chalk.dim(r.scope)} → ${val}`)
90
- }
91
- }
92
- } catch (err) {
93
- console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
94
- process.exit(1)
95
- } finally {
96
- if (db) await shutdown(db)
97
- }
98
- })
99
- }
@@ -1,79 +0,0 @@
1
- import type { FeatureStore } from '../feature_store.ts'
2
- import type { ScopeKey, StoredFeature } from '../types.ts'
3
-
4
- /** In-memory feature store for testing. */
5
- export class ArrayDriver implements FeatureStore {
6
- readonly name = 'array'
7
- private data = new Map<string, { value: unknown; createdAt: Date; updatedAt: Date }>()
8
-
9
- private key(feature: string, scope: ScopeKey): string {
10
- return `${feature}\0${scope}`
11
- }
12
-
13
- async get(feature: string, scope: ScopeKey): Promise<unknown | undefined> {
14
- return this.data.get(this.key(feature, scope))?.value
15
- }
16
-
17
- async getMany(features: string[], scope: ScopeKey): Promise<Map<string, unknown>> {
18
- const result = new Map<string, unknown>()
19
- for (const feature of features) {
20
- const entry = this.data.get(this.key(feature, scope))
21
- if (entry !== undefined) result.set(feature, entry.value)
22
- }
23
- return result
24
- }
25
-
26
- async set(feature: string, scope: ScopeKey, value: unknown): Promise<void> {
27
- const existing = this.data.get(this.key(feature, scope))
28
- const now = new Date()
29
- this.data.set(this.key(feature, scope), {
30
- value,
31
- createdAt: existing?.createdAt ?? now,
32
- updatedAt: now,
33
- })
34
- }
35
-
36
- async setMany(
37
- entries: Array<{ feature: string; scope: ScopeKey; value: unknown }>
38
- ): Promise<void> {
39
- for (const e of entries) await this.set(e.feature, e.scope, e.value)
40
- }
41
-
42
- async forget(feature: string, scope: ScopeKey): Promise<void> {
43
- this.data.delete(this.key(feature, scope))
44
- }
45
-
46
- async purge(feature: string): Promise<void> {
47
- for (const key of this.data.keys()) {
48
- if (key.startsWith(`${feature}\0`)) this.data.delete(key)
49
- }
50
- }
51
-
52
- async purgeAll(): Promise<void> {
53
- this.data.clear()
54
- }
55
-
56
- async featureNames(): Promise<string[]> {
57
- const names = new Set<string>()
58
- for (const key of this.data.keys()) {
59
- names.add(key.split('\0')[0]!)
60
- }
61
- return [...names]
62
- }
63
-
64
- async allFor(feature: string): Promise<StoredFeature[]> {
65
- const results: StoredFeature[] = []
66
- for (const [key, entry] of this.data) {
67
- if (key.startsWith(`${feature}\0`)) {
68
- results.push({
69
- feature,
70
- scope: key.split('\0')[1]!,
71
- value: entry.value,
72
- createdAt: entry.createdAt,
73
- updatedAt: entry.updatedAt,
74
- })
75
- }
76
- }
77
- return results
78
- }
79
- }