@strav/flag 0.3.32 → 0.3.33

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/flag",
3
- "version": "0.3.32",
3
+ "version": "0.3.33",
4
4
  "type": "module",
5
5
  "description": "Feature flags for the Strav framework",
6
6
  "license": "MIT",
@@ -18,10 +18,10 @@
18
18
  "tsconfig.json"
19
19
  ],
20
20
  "peerDependencies": {
21
- "@strav/kernel": "0.3.32",
22
- "@strav/database": "0.3.32",
23
- "@strav/http": "0.3.32",
24
- "@strav/cli": "0.3.32"
21
+ "@strav/kernel": "0.3.33",
22
+ "@strav/database": "0.3.33",
23
+ "@strav/http": "0.3.33",
24
+ "@strav/cli": "0.3.33"
25
25
  },
26
26
  "scripts": {
27
27
  "test": "bun test tests/",
package/src/errors.ts CHANGED
@@ -7,3 +7,19 @@ export class FeatureNotDefinedError extends FlagError {
7
7
  super(`Feature "${feature}" is not defined. Register it with flag.define().`)
8
8
  }
9
9
  }
10
+
11
+ /**
12
+ * Thrown when `flag.strictScopes` is enabled and a flag operation is
13
+ * called without a scope (or with a null/undefined one). Catches the
14
+ * common bug where middleware forgets to pass `ctx.get('user')` and
15
+ * the lookup silently evaluates the global flag.
16
+ */
17
+ export class MissingScopeError extends FlagError {
18
+ constructor(feature: string) {
19
+ super(
20
+ `Feature "${feature}" was evaluated without a scope, but flag.strictScopes is enabled. ` +
21
+ `Pass an explicit scope (e.g. flag.for(user).value('${feature}')) or call ` +
22
+ `flag.activateForEveryone('${feature}') for genuinely global flags.`
23
+ )
24
+ }
25
+ }
@@ -5,6 +5,7 @@ import type {
5
5
  DriverConfig,
6
6
  FeatureResolver,
7
7
  FeatureClassConstructor,
8
+ FlagActor,
8
9
  ScopeKey,
9
10
  Scopeable,
10
11
  } from './types.ts'
@@ -12,7 +13,7 @@ import { GLOBAL_SCOPE } from './types.ts'
12
13
  import type { FeatureStore } from './feature_store.ts'
13
14
  import { DatabaseDriver } from './drivers/database_driver.ts'
14
15
  import { ArrayDriver } from './drivers/array_driver.ts'
15
- import { FeatureNotDefinedError } from './errors.ts'
16
+ import { FeatureNotDefinedError, MissingScopeError } from './errors.ts'
16
17
  import PendingScopedFeature from './pending_scope.ts'
17
18
 
18
19
  @inject
@@ -30,6 +31,7 @@ export default class FlagManager {
30
31
  FlagManager._config = {
31
32
  default: config.get('flag.default', 'database') as string,
32
33
  drivers: config.get('flag.drivers', {}) as Record<string, DriverConfig>,
34
+ strictScopes: config.get('flag.strictScopes', false) as boolean,
33
35
  }
34
36
  }
35
37
 
@@ -74,10 +76,31 @@ export default class FlagManager {
74
76
  return `${type}:${scope.id}`
75
77
  }
76
78
 
79
+ /**
80
+ * Resolve a scope to its key, applying the `strictScopes` policy on
81
+ * the READ path. A null/undefined scope under strict mode raises
82
+ * `MissingScopeError` so a forgotten request-context lookup doesn't
83
+ * silently return the global value.
84
+ *
85
+ * The WRITE path keeps the loose semantics — `activate(feature)`
86
+ * with no scope still writes to the global. Callers that want
87
+ * explicit-global writes should prefer `activateForEveryone()` /
88
+ * `deactivateForEveryone()`.
89
+ */
90
+ private static resolveScopeStrict(
91
+ feature: string,
92
+ scope: Scopeable | null | undefined
93
+ ): ScopeKey {
94
+ if (!scope && FlagManager._config?.strictScopes) {
95
+ throw new MissingScopeError(feature)
96
+ }
97
+ return FlagManager.serializeScope(scope)
98
+ }
99
+
77
100
  // ── Core resolution ────────────────────────────────────────────────
78
101
 
79
102
  static async value(feature: string, scope?: Scopeable | null): Promise<unknown> {
80
- const scopeKey = FlagManager.serializeScope(scope)
103
+ const scopeKey = FlagManager.resolveScopeStrict(feature, scope)
81
104
  const cacheKey = FlagManager.cacheKey(feature, scopeKey)
82
105
 
83
106
  // 1. Check in-memory cache
@@ -131,34 +154,80 @@ export default class FlagManager {
131
154
 
132
155
  // ── Manual activation/deactivation ─────────────────────────────────
133
156
 
134
- static async activate(feature: string, value?: unknown, scope?: Scopeable | null): Promise<void> {
157
+ /**
158
+ * Turn a flag on (or assign a rich value).
159
+ *
160
+ * Pass `actor` to record who made the change — the value is included
161
+ * in the `flag:updated` event payload so an audit hook can wire it
162
+ * through to `@strav/audit`. See the package CLAUDE.md for the
163
+ * recommended one-liner pattern.
164
+ */
165
+ static async activate(
166
+ feature: string,
167
+ value?: unknown,
168
+ scope?: Scopeable | null,
169
+ actor?: FlagActor | null
170
+ ): Promise<void> {
135
171
  const scopeKey = FlagManager.serializeScope(scope)
136
172
  const resolved = value !== undefined ? value : true
173
+ const previous = await FlagManager.store().get(feature, scopeKey)
137
174
  await FlagManager.store().set(feature, scopeKey, resolved)
138
175
  FlagManager._cache.set(FlagManager.cacheKey(feature, scopeKey), resolved)
139
- await Emitter.emit('flag:updated', { feature, scope: scopeKey, value: resolved })
176
+ await Emitter.emit('flag:updated', {
177
+ feature,
178
+ scope: scopeKey,
179
+ value: resolved,
180
+ previous,
181
+ actor: actor ?? null,
182
+ })
140
183
  }
141
184
 
142
- static async deactivate(feature: string, scope?: Scopeable | null): Promise<void> {
185
+ /**
186
+ * Turn a flag off.
187
+ *
188
+ * Pass `actor` to record who made the change — see {@link activate}.
189
+ */
190
+ static async deactivate(
191
+ feature: string,
192
+ scope?: Scopeable | null,
193
+ actor?: FlagActor | null
194
+ ): Promise<void> {
143
195
  const scopeKey = FlagManager.serializeScope(scope)
196
+ const previous = await FlagManager.store().get(feature, scopeKey)
144
197
  await FlagManager.store().set(feature, scopeKey, false)
145
198
  FlagManager._cache.set(FlagManager.cacheKey(feature, scopeKey), false)
146
- await Emitter.emit('flag:updated', { feature, scope: scopeKey, value: false })
199
+ await Emitter.emit('flag:updated', {
200
+ feature,
201
+ scope: scopeKey,
202
+ value: false,
203
+ previous,
204
+ actor: actor ?? null,
205
+ })
147
206
  }
148
207
 
149
- static async activateForEveryone(feature: string, value?: unknown): Promise<void> {
150
- return FlagManager.activate(feature, value)
208
+ static async activateForEveryone(
209
+ feature: string,
210
+ value?: unknown,
211
+ actor?: FlagActor | null
212
+ ): Promise<void> {
213
+ return FlagManager.activate(feature, value, null, actor)
151
214
  }
152
215
 
153
- static async deactivateForEveryone(feature: string): Promise<void> {
154
- return FlagManager.deactivate(feature)
216
+ static async deactivateForEveryone(
217
+ feature: string,
218
+ actor?: FlagActor | null
219
+ ): Promise<void> {
220
+ return FlagManager.deactivate(feature, null, actor)
155
221
  }
156
222
 
157
223
  // ── Batch operations ───────────────────────────────────────────────
158
224
 
159
225
  static async values(features: string[], scope?: Scopeable | null): Promise<Map<string, unknown>> {
160
226
  const result = new Map<string, unknown>()
161
- const scopeKey = FlagManager.serializeScope(scope)
227
+ // Use the first feature name in the strict-scope error message — every
228
+ // batch call shares the same scope so the missing-scope diagnosis is
229
+ // identical regardless of which feature trips the check.
230
+ const scopeKey = FlagManager.resolveScopeStrict(features[0] ?? '<batch>', scope)
162
231
 
163
232
  // Collect cache hits and misses
164
233
  const misses: string[] = []
@@ -231,7 +300,7 @@ export default class FlagManager {
231
300
  // ── Cleanup ────────────────────────────────────────────────────────
232
301
 
233
302
  static async forget(feature: string, scope?: Scopeable | null): Promise<void> {
234
- const scopeKey = FlagManager.serializeScope(scope)
303
+ const scopeKey = FlagManager.resolveScopeStrict(feature, scope)
235
304
  await FlagManager.store().forget(feature, scopeKey)
236
305
  FlagManager._cache.delete(FlagManager.cacheKey(feature, scopeKey))
237
306
  await Emitter.emit('flag:deleted', { feature, scope: scopeKey })
package/src/index.ts CHANGED
@@ -22,7 +22,7 @@ export { default as PendingScopedFeature } from './pending_scope.ts'
22
22
  export { ensureFeature } from './middleware/ensure_feature.ts'
23
23
 
24
24
  // Errors
25
- export { FlagError, FeatureNotDefinedError } from './errors.ts'
25
+ export { FlagError, FeatureNotDefinedError, MissingScopeError } from './errors.ts'
26
26
 
27
27
  // Types
28
28
  export type {
@@ -34,5 +34,7 @@ export type {
34
34
  FeatureResolver,
35
35
  FeatureClass,
36
36
  FeatureClassConstructor,
37
+ FlagActor,
38
+ FlagUpdatedEvent,
37
39
  } from './types.ts'
38
40
  export { GLOBAL_SCOPE } from './types.ts'
@@ -1,4 +1,4 @@
1
- import type { Scopeable } from './types.ts'
1
+ import type { FlagActor, Scopeable } from './types.ts'
2
2
  import FlagManager from './flag_manager.ts'
3
3
 
4
4
  /** Fluent scoped feature check — created by `FlagManager.for(scope)`. */
@@ -25,12 +25,12 @@ export default class PendingScopedFeature {
25
25
  return FlagManager.when(feature, onActive, onInactive, this.scope)
26
26
  }
27
27
 
28
- activate(feature: string, value?: unknown): Promise<void> {
29
- return FlagManager.activate(feature, value, this.scope)
28
+ activate(feature: string, value?: unknown, actor?: FlagActor | null): Promise<void> {
29
+ return FlagManager.activate(feature, value, this.scope, actor)
30
30
  }
31
31
 
32
- deactivate(feature: string): Promise<void> {
33
- return FlagManager.deactivate(feature, this.scope)
32
+ deactivate(feature: string, actor?: FlagActor | null): Promise<void> {
33
+ return FlagManager.deactivate(feature, this.scope, actor)
34
34
  }
35
35
 
36
36
  forget(feature: string): Promise<void> {
package/src/types.ts CHANGED
@@ -44,9 +44,48 @@ export interface StoredFeature {
44
44
  export interface FlagConfig {
45
45
  default: string
46
46
  drivers: Record<string, DriverConfig>
47
+ /**
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.
55
+ */
56
+ strictScopes: boolean
47
57
  }
48
58
 
49
59
  export interface DriverConfig {
50
60
  driver: string
51
61
  [key: string]: unknown
52
62
  }
63
+
64
+ // ── Actor ────────────────────────────────────────────────────────────────
65
+
66
+ /**
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.
71
+ */
72
+ export interface FlagActor {
73
+ type: string
74
+ id: string | number
75
+ }
76
+
77
+ // ── Events ───────────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Payload for the `flag:updated` Emitter event. Fired when `activate()`
81
+ * or `deactivate()` writes to the store.
82
+ */
83
+ export interface FlagUpdatedEvent {
84
+ feature: string
85
+ scope: ScopeKey
86
+ value: unknown
87
+ /** Previous stored value (if any) — undefined when the flag had no prior store entry. */
88
+ previous: unknown
89
+ /** Who initiated the change. `null` when the caller did not provide an actor. */
90
+ actor: FlagActor | null
91
+ }