@strav/flag 0.3.32 → 0.4.0
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 +5 -5
- package/src/errors.ts +16 -0
- package/src/flag_manager.ts +81 -12
- package/src/index.ts +3 -1
- package/src/pending_scope.ts +5 -5
- package/src/types.ts +39 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/flag",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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.
|
|
22
|
-
"@strav/database": "0.
|
|
23
|
-
"@strav/http": "0.
|
|
24
|
-
"@strav/cli": "0.
|
|
21
|
+
"@strav/kernel": "0.4.0",
|
|
22
|
+
"@strav/database": "0.4.0",
|
|
23
|
+
"@strav/http": "0.4.0",
|
|
24
|
+
"@strav/cli": "0.4.0"
|
|
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
|
+
}
|
package/src/flag_manager.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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', {
|
|
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
|
-
|
|
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', {
|
|
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(
|
|
150
|
-
|
|
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(
|
|
154
|
-
|
|
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
|
-
|
|
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.
|
|
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'
|
package/src/pending_scope.ts
CHANGED
|
@@ -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
|
+
}
|