@quonfig/cli 0.0.51 → 0.0.53
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/README.md +102 -44
- package/dist/commands/run.js +0 -1
- package/dist/commands/serve.d.ts +23 -0
- package/dist/commands/serve.js +136 -0
- package/dist/migrate/sources/flagsmith/translate.js +46 -0
- package/dist/migrate/sources/flagsmith.js +0 -20
- package/dist/quonfig.js +0 -1
- package/dist/serve/datadir-discovery.d.ts +33 -0
- package/dist/serve/datadir-discovery.js +38 -0
- package/dist/serve/handlers/evalContext.d.ts +66 -0
- package/dist/serve/handlers/evalContext.js +170 -0
- package/dist/serve/server.d.ts +53 -0
- package/dist/serve/server.js +355 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +116 -1
- package/package.json +2 -2
|
@@ -76,6 +76,29 @@ function mvOptionValue(opt) {
|
|
|
76
76
|
return opt.integer_value ?? 0;
|
|
77
77
|
return opt.string_value ?? '';
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* True when a STANDARD feature has no value payload in any env — every
|
|
81
|
+
* env-default's `feature_state_value` envelope is shaped `{type: 'unicode',
|
|
82
|
+
* boolean_value: null, integer_value: null, string_value: null}` (the default
|
|
83
|
+
* Flagsmith emits for bool-only flags). In that shape the per-env `enabled`
|
|
84
|
+
* bit IS the value; treating the flag as `string` and coercing the empty
|
|
85
|
+
* envelope to `''` would silently lose the bool. The detection is bundle-pure
|
|
86
|
+
* so both `decideValueType` and the value-emission paths can re-check it.
|
|
87
|
+
*/
|
|
88
|
+
function isValuelessStandardBool(bundle) {
|
|
89
|
+
const { feature, featurestates_by_env } = bundle;
|
|
90
|
+
if (feature.type !== 'STANDARD')
|
|
91
|
+
return false;
|
|
92
|
+
const envs = Object.values(featurestates_by_env);
|
|
93
|
+
if (envs.length === 0)
|
|
94
|
+
return false;
|
|
95
|
+
return envs.every((env) => {
|
|
96
|
+
const fsv = env.default?.feature_state_value;
|
|
97
|
+
if (!fsv || typeof fsv !== 'object')
|
|
98
|
+
return false;
|
|
99
|
+
return fsv.boolean_value === null && fsv.integer_value === null && fsv.string_value === null;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
79
102
|
/**
|
|
80
103
|
* Decide the feature's Quonfig valueType. Multivariate features take their
|
|
81
104
|
* type from the variation values (all variants share a type in valid Flagsmith
|
|
@@ -86,6 +109,9 @@ function mvOptionValue(opt) {
|
|
|
86
109
|
function decideValueType(bundle, report) {
|
|
87
110
|
const { feature, featurestates_by_env } = bundle;
|
|
88
111
|
const mvOptions = feature.multivariate_options ?? [];
|
|
112
|
+
// Value-less STANDARD feature: enabled IS the bool value (qfg-ybt9).
|
|
113
|
+
if (isValuelessStandardBool(bundle))
|
|
114
|
+
return 'bool';
|
|
89
115
|
if (feature.type === 'MULTIVARIATE' && mvOptions.length > 0) {
|
|
90
116
|
const variantValues = mvOptions.map((o) => mvOptionValue(o));
|
|
91
117
|
const types = new Set(variantValues.map((v) => inferValueType(v)));
|
|
@@ -337,6 +363,10 @@ function flattenSegmentRuleForSegment(rule, segment, report) {
|
|
|
337
363
|
function envDefaultRuleValue(bundle, envApiKey, fsState, ctx) {
|
|
338
364
|
const { feature } = bundle;
|
|
339
365
|
const mvOptions = feature.multivariate_options ?? [];
|
|
366
|
+
// Value-less STANDARD bool (qfg-ybt9): enabled IS the value; envelope is empty.
|
|
367
|
+
if (isValuelessStandardBool(bundle)) {
|
|
368
|
+
return { type: 'bool', value: fsState?.enabled ?? false };
|
|
369
|
+
}
|
|
340
370
|
// enabled=false (D-F1)
|
|
341
371
|
if (fsState && !fsState.enabled) {
|
|
342
372
|
if (ctx.valueType === 'bool') {
|
|
@@ -369,6 +399,10 @@ function identityOverrideRuleValue(bundle, override, ctx) {
|
|
|
369
399
|
const { feature } = bundle;
|
|
370
400
|
const mvOptions = feature.multivariate_options ?? [];
|
|
371
401
|
const mvWeights = override.feature_state.multivariate_feature_state_values ?? [];
|
|
402
|
+
// Value-less STANDARD bool (qfg-ybt9): override's enabled IS the value.
|
|
403
|
+
if (isValuelessStandardBool(bundle)) {
|
|
404
|
+
return { type: 'bool', value: override.feature_state.enabled };
|
|
405
|
+
}
|
|
372
406
|
// enabled=false on the override: prefer the served value of the override's
|
|
373
407
|
// own feature_state_value, with the same D-F1 reporting if non-boolean.
|
|
374
408
|
if (!override.feature_state.enabled) {
|
|
@@ -397,6 +431,10 @@ function identityOverrideRuleValue(bundle, override, ctx) {
|
|
|
397
431
|
function servedValueSilent(bundle, fsState, ctx) {
|
|
398
432
|
const { feature } = bundle;
|
|
399
433
|
const mvOptions = feature.multivariate_options ?? [];
|
|
434
|
+
// Value-less STANDARD bool (qfg-ybt9): enabled IS the value.
|
|
435
|
+
if (isValuelessStandardBool(bundle)) {
|
|
436
|
+
return { type: 'bool', value: fsState?.enabled ?? false };
|
|
437
|
+
}
|
|
400
438
|
if (fsState && !fsState.enabled && ctx.valueType === 'bool') {
|
|
401
439
|
return { type: 'bool', value: false };
|
|
402
440
|
}
|
|
@@ -502,6 +540,14 @@ export function translateFeature(bundle, report, options = {}) {
|
|
|
502
540
|
segmentIdByFeatureSegmentId[envApiKey] = m;
|
|
503
541
|
}
|
|
504
542
|
const ctx = { feature, report, segmentNameById, tagLabelById, valueType };
|
|
543
|
+
// Plan §5.4: every MULTIVARIATE feature triggers a re-bucketing note —
|
|
544
|
+
// Flagsmith and Quonfig hash identities with different salts, so individual
|
|
545
|
+
// identity → variation assignments change post-migration regardless of any
|
|
546
|
+
// per-env override structure. The overall percentage allocation is preserved.
|
|
547
|
+
if (feature.type === 'MULTIVARIATE' && (feature.multivariate_options?.length ?? 0) > 0) {
|
|
548
|
+
report.add('rebucketed-rollout', feature.name, 'multivariate feature; Flagsmith and Quonfig hash identities into buckets with different salts, so ' +
|
|
549
|
+
'individual identity → variation assignments will change after migration (the overall percentage allocation is preserved)');
|
|
550
|
+
}
|
|
505
551
|
// Per-env rule list synthesis (plan §5.1, D-F2 ordering): identity overrides
|
|
506
552
|
// first, then segment overrides (already sorted ASC by priority by the
|
|
507
553
|
// fetcher), then the trailing ALWAYS_TRUE serving the env default.
|
|
@@ -44,7 +44,6 @@ function resolveProjectId() {
|
|
|
44
44
|
const state = {
|
|
45
45
|
apiKey: null,
|
|
46
46
|
environments: [],
|
|
47
|
-
multivariateFeatures: [],
|
|
48
47
|
project: null,
|
|
49
48
|
projectId: resolveProjectId(),
|
|
50
49
|
report: new ConversionReport(),
|
|
@@ -95,7 +94,6 @@ async function validateAuthImpl(apiKey) {
|
|
|
95
94
|
state.project = project;
|
|
96
95
|
state.report = new ConversionReport();
|
|
97
96
|
state.environments = [];
|
|
98
|
-
state.multivariateFeatures = [];
|
|
99
97
|
state.segments = [];
|
|
100
98
|
state.tags = [];
|
|
101
99
|
}
|
|
@@ -129,9 +127,6 @@ async function* fetchChangesImpl() {
|
|
|
129
127
|
state.environments = snapshot.environments;
|
|
130
128
|
state.segments = snapshot.segments;
|
|
131
129
|
state.tags = snapshot.tags;
|
|
132
|
-
state.multivariateFeatures = snapshot.features
|
|
133
|
-
.filter((bundle) => bundle.feature.type === 'MULTIVARIATE' && (bundle.feature.multivariate_options?.length ?? 0) > 0)
|
|
134
|
-
.map((bundle) => bundle.feature.name);
|
|
135
130
|
for (const featureBundle of snapshot.features) {
|
|
136
131
|
yield {
|
|
137
132
|
key: featureBundle.feature.name,
|
|
@@ -316,20 +311,6 @@ function getConversionNotesImpl() {
|
|
|
316
311
|
return out;
|
|
317
312
|
};
|
|
318
313
|
const notes = state.report.all().map((n) => ({ ...n, detail: decorate(n.detail) }));
|
|
319
|
-
// Synthesize rebucketed-rollout notes for every MULTIVARIATE feature so the
|
|
320
|
-
// dedicated "Users will be re-bucketed" section in MIGRATION_REPORT.md
|
|
321
|
-
// names each one (plan §5.4). Flagsmith and Quonfig hash buckets with
|
|
322
|
-
// different salts; identity-level assignments WILL change post-migration
|
|
323
|
-
// for every MV flag, regardless of whether the allocation has any per-env
|
|
324
|
-
// overrides.
|
|
325
|
-
for (const featureName of state.multivariateFeatures) {
|
|
326
|
-
notes.push({
|
|
327
|
-
category: 'rebucketed-rollout',
|
|
328
|
-
detail: 'multivariate feature; Flagsmith and Quonfig hash identities into buckets with different salts, so ' +
|
|
329
|
-
'individual identity → variation assignments will change after migration (the overall percentage allocation is preserved)',
|
|
330
|
-
key: featureName,
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
314
|
// Identity-trait references — pure FYI, no action required server-side, but
|
|
334
315
|
// every trait must be sent by the SDK caller at eval time or matching rules
|
|
335
316
|
// will silently miss. Plan §5.5 / D-F6.
|
|
@@ -376,7 +357,6 @@ export const flagsmithSource = {
|
|
|
376
357
|
export function __resetFlagsmithSourceForTests() {
|
|
377
358
|
state.apiKey = null;
|
|
378
359
|
state.environments = [];
|
|
379
|
-
state.multivariateFeatures = [];
|
|
380
360
|
state.project = null;
|
|
381
361
|
state.projectId = resolveProjectId();
|
|
382
362
|
state.report = new ConversionReport();
|
package/dist/quonfig.js
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Datadir discovery for `qfg serve`.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order (plan §4):
|
|
5
|
+
* 1. --datadir flag
|
|
6
|
+
* 2. QUONFIG_DIR env var
|
|
7
|
+
* 3. ./our-config if present
|
|
8
|
+
* 4. ./.quonfig if present
|
|
9
|
+
* 5. error
|
|
10
|
+
*
|
|
11
|
+
* Kept narrow on purpose: `qfg pull`/`qfg push` use a richer
|
|
12
|
+
* `resolveWorkspaceDir` helper that walks up looking for `quonfig.json` — but
|
|
13
|
+
* `qfg serve` is intentionally cwd-relative (the user said "serve THIS dir")
|
|
14
|
+
* and the walk would surface a confusing path on a misconfigured repo. The
|
|
15
|
+
* default-pair lookup mirrors `qfg pull --dir` defaults documented in the
|
|
16
|
+
* plan.
|
|
17
|
+
*/
|
|
18
|
+
export declare const DEFAULT_DATADIR_CANDIDATES: readonly ["our-config", ".quonfig"];
|
|
19
|
+
export declare const NO_DATADIR_ERROR: string;
|
|
20
|
+
export interface ResolveDatadirInput {
|
|
21
|
+
cwd: string;
|
|
22
|
+
envDatadir: string | undefined;
|
|
23
|
+
flagDatadir: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
export type ResolveDatadirResult = {
|
|
26
|
+
kind: 'ok';
|
|
27
|
+
dir: string;
|
|
28
|
+
source: 'flag' | 'env' | 'cwd-default';
|
|
29
|
+
} | {
|
|
30
|
+
kind: 'error';
|
|
31
|
+
message: string;
|
|
32
|
+
};
|
|
33
|
+
export declare const resolveDatadirForServe: (input: ResolveDatadirInput) => ResolveDatadirResult;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Datadir discovery for `qfg serve`.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order (plan §4):
|
|
5
|
+
* 1. --datadir flag
|
|
6
|
+
* 2. QUONFIG_DIR env var
|
|
7
|
+
* 3. ./our-config if present
|
|
8
|
+
* 4. ./.quonfig if present
|
|
9
|
+
* 5. error
|
|
10
|
+
*
|
|
11
|
+
* Kept narrow on purpose: `qfg pull`/`qfg push` use a richer
|
|
12
|
+
* `resolveWorkspaceDir` helper that walks up looking for `quonfig.json` — but
|
|
13
|
+
* `qfg serve` is intentionally cwd-relative (the user said "serve THIS dir")
|
|
14
|
+
* and the walk would surface a confusing path on a misconfigured repo. The
|
|
15
|
+
* default-pair lookup mirrors `qfg pull --dir` defaults documented in the
|
|
16
|
+
* plan.
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from 'node:fs';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
export const DEFAULT_DATADIR_CANDIDATES = ['our-config', '.quonfig'];
|
|
21
|
+
export const NO_DATADIR_ERROR = 'No datadir found. Pass --datadir <path>, set QUONFIG_DIR, ' +
|
|
22
|
+
`or run from a directory containing ${DEFAULT_DATADIR_CANDIDATES.map((c) => `./${c}`).join(' or ')}.`;
|
|
23
|
+
const blank = (s) => s === undefined || s === '';
|
|
24
|
+
export const resolveDatadirForServe = (input) => {
|
|
25
|
+
if (!blank(input.flagDatadir)) {
|
|
26
|
+
return { kind: 'ok', dir: path.resolve(input.cwd, input.flagDatadir), source: 'flag' };
|
|
27
|
+
}
|
|
28
|
+
if (!blank(input.envDatadir)) {
|
|
29
|
+
return { kind: 'ok', dir: path.resolve(input.cwd, input.envDatadir), source: 'env' };
|
|
30
|
+
}
|
|
31
|
+
for (const candidate of DEFAULT_DATADIR_CANDIDATES) {
|
|
32
|
+
const abs = path.resolve(input.cwd, candidate);
|
|
33
|
+
if (fs.existsSync(abs)) {
|
|
34
|
+
return { kind: 'ok', dir: abs, source: 'cwd-default' };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { kind: 'error', message: NO_DATADIR_ERROR };
|
|
38
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handler for `GET /api/v2/configs/eval-with-context/{base64url(ctx)}`.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors api-delivery/internal/serve/eval_context.go so SDK clients see the
|
|
5
|
+
* same EvalEnvelope shape they get in production. The fields are:
|
|
6
|
+
*
|
|
7
|
+
* { evaluations: { [key]: {
|
|
8
|
+
* value: { type, value, confidential?, decryptWith? },
|
|
9
|
+
* configId, configType, valueType, reason,
|
|
10
|
+
* ruleIndex?, weightedValueIndex?
|
|
11
|
+
* } },
|
|
12
|
+
* meta: { version, environment }
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* `reason` follows the cross-SDK spec
|
|
16
|
+
* `project/plans/openfeature-resolution-details.md`:
|
|
17
|
+
* - "SPLIT" for weighted-value flags
|
|
18
|
+
* - "TARGETING_MATCH" for configs whose matched rule has a non-ALWAYS_TRUE criterion
|
|
19
|
+
* - "STATIC" otherwise
|
|
20
|
+
*
|
|
21
|
+
* `ruleIndex` and `weightedValueIndex` are emitted only when the reason class
|
|
22
|
+
* carries useful information (TARGETING_MATCH or SPLIT). This matches the Go
|
|
23
|
+
* handler's "omit, don't null" rule.
|
|
24
|
+
*/
|
|
25
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
26
|
+
import type { Evaluator, ConfigStore } from '@quonfig/node';
|
|
27
|
+
export interface EvalEnvelope {
|
|
28
|
+
evaluations: Record<string, EvalResult>;
|
|
29
|
+
meta: {
|
|
30
|
+
version: string;
|
|
31
|
+
environment: string;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export interface EvalResult {
|
|
35
|
+
configId: string;
|
|
36
|
+
configType: string;
|
|
37
|
+
reason: string;
|
|
38
|
+
ruleIndex?: number;
|
|
39
|
+
value: {
|
|
40
|
+
type: string;
|
|
41
|
+
value: unknown;
|
|
42
|
+
confidential?: boolean;
|
|
43
|
+
decryptWith?: string;
|
|
44
|
+
};
|
|
45
|
+
valueType: string;
|
|
46
|
+
weightedValueIndex?: number;
|
|
47
|
+
}
|
|
48
|
+
export interface EvalHandlerDeps {
|
|
49
|
+
/**
|
|
50
|
+
* Snapshot accessor — returns the *current* store + evaluator + meta. We
|
|
51
|
+
* read this on every request so that auto-reload swaps under us
|
|
52
|
+
* transparently.
|
|
53
|
+
*
|
|
54
|
+
* Note (qfg-38sf.1 audit, F3): the sendToClientSdk filter is unconditional
|
|
55
|
+
* and lives inside this handler. There is no `frontendFilter` toggle —
|
|
56
|
+
* every qfg serve consumer is a frontend client by definition, so the
|
|
57
|
+
* filter always applies.
|
|
58
|
+
*/
|
|
59
|
+
getSnapshot(): {
|
|
60
|
+
store: ConfigStore;
|
|
61
|
+
evaluator: Evaluator;
|
|
62
|
+
environment: string;
|
|
63
|
+
version: string;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export declare function handleEvalContext(req: IncomingMessage, res: ServerResponse, ctxToken: string, deps: EvalHandlerDeps): Promise<void>;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handler for `GET /api/v2/configs/eval-with-context/{base64url(ctx)}`.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors api-delivery/internal/serve/eval_context.go so SDK clients see the
|
|
5
|
+
* same EvalEnvelope shape they get in production. The fields are:
|
|
6
|
+
*
|
|
7
|
+
* { evaluations: { [key]: {
|
|
8
|
+
* value: { type, value, confidential?, decryptWith? },
|
|
9
|
+
* configId, configType, valueType, reason,
|
|
10
|
+
* ruleIndex?, weightedValueIndex?
|
|
11
|
+
* } },
|
|
12
|
+
* meta: { version, environment }
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* `reason` follows the cross-SDK spec
|
|
16
|
+
* `project/plans/openfeature-resolution-details.md`:
|
|
17
|
+
* - "SPLIT" for weighted-value flags
|
|
18
|
+
* - "TARGETING_MATCH" for configs whose matched rule has a non-ALWAYS_TRUE criterion
|
|
19
|
+
* - "STATIC" otherwise
|
|
20
|
+
*
|
|
21
|
+
* `ruleIndex` and `weightedValueIndex` are emitted only when the reason class
|
|
22
|
+
* carries useful information (TARGETING_MATCH or SPLIT). This matches the Go
|
|
23
|
+
* handler's "omit, don't null" rule.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* G6 (qfg-38sf.1 audit): cap on the raw base64 context token length before
|
|
27
|
+
* we allocate a Buffer for it. 64 KB is well above the realistic size of any
|
|
28
|
+
* SDK-sent context envelope (Quonfig contexts are typically a few hundred
|
|
29
|
+
* bytes; the SDK base64-encodes a small JSON object) while still bounded
|
|
30
|
+
* enough to prevent a malicious client from forcing the server to allocate
|
|
31
|
+
* a multi-megabyte buffer per request.
|
|
32
|
+
*/
|
|
33
|
+
const MAX_CTX_TOKEN_BYTES = 64 * 1024;
|
|
34
|
+
export async function handleEvalContext(req, res, ctxToken, deps) {
|
|
35
|
+
// G6: bound the token size BEFORE we decode it. The 64KB cap is way above
|
|
36
|
+
// realistic context payloads and small enough that a malicious caller
|
|
37
|
+
// can't trick us into allocating gigabytes. 414 URI Too Long is the right
|
|
38
|
+
// status — RFC 7231 §6.5.12 covers exactly this case (the URL is the
|
|
39
|
+
// request-target, the ctx is a path segment, so it's a URI-length problem,
|
|
40
|
+
// not a body-length problem).
|
|
41
|
+
if (ctxToken.length > MAX_CTX_TOKEN_BYTES) {
|
|
42
|
+
res.writeHead(414, { 'Content-Type': 'text/plain' });
|
|
43
|
+
res.end('context token too large');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// base64url decode → JSON. We accept the URL-safe alphabet and the
|
|
47
|
+
// standard one because the Go handler does (`base64.URLEncoding`,
|
|
48
|
+
// `StdEncoding`, `RawURLEncoding` — Node's "base64url" parser already
|
|
49
|
+
// covers the no-padding URL-safe case; we try standard as a fallback).
|
|
50
|
+
let contextData;
|
|
51
|
+
try {
|
|
52
|
+
let json;
|
|
53
|
+
try {
|
|
54
|
+
json = Buffer.from(ctxToken, 'base64url').toString('utf8');
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
json = Buffer.from(ctxToken, 'base64').toString('utf8');
|
|
58
|
+
}
|
|
59
|
+
contextData = JSON.parse(json);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
63
|
+
res.end('invalid context token encoding');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const snap = deps.getSnapshot();
|
|
67
|
+
const evaluations = {};
|
|
68
|
+
// The SDK's Evaluator takes a `Contexts` map shaped exactly like the JSON
|
|
69
|
+
// we just decoded: { [contextName]: { [key]: value } }. No translation
|
|
70
|
+
// needed.
|
|
71
|
+
for (const key of snap.store.keys()) {
|
|
72
|
+
const cfg = snap.store.get(key);
|
|
73
|
+
if (!cfg)
|
|
74
|
+
continue;
|
|
75
|
+
// F3 (qfg-38sf.1 audit): UNCONDITIONAL sendToClientSdk filter. Every qfg
|
|
76
|
+
// serve consumer is a frontend client by definition, so we apply the
|
|
77
|
+
// same gate api-delivery uses for frontend SDK keys
|
|
78
|
+
// (eval_context.go:103) regardless of whether --frontend-sdk-key was
|
|
79
|
+
// passed. Feature flags bypass the filter, matching the Go behavior.
|
|
80
|
+
if (cfg.type !== 'feature_flag' && !cfg.sendToClientSdk) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const match = snap.evaluator.evaluateConfig(cfg, snap.environment, contextData);
|
|
84
|
+
if (!match.isMatch || match.value === undefined)
|
|
85
|
+
continue;
|
|
86
|
+
const reason = resolutionReason(cfg, match);
|
|
87
|
+
const result = {
|
|
88
|
+
value: {
|
|
89
|
+
type: match.value.type,
|
|
90
|
+
value: coerceNumeric(match.value.type, match.value.value),
|
|
91
|
+
},
|
|
92
|
+
configId: cfg.id,
|
|
93
|
+
configType: cfg.type,
|
|
94
|
+
valueType: cfg.valueType,
|
|
95
|
+
reason,
|
|
96
|
+
};
|
|
97
|
+
if (match.value.confidential !== undefined)
|
|
98
|
+
result.value.confidential = match.value.confidential;
|
|
99
|
+
if (match.value.decryptWith !== undefined)
|
|
100
|
+
result.value.decryptWith = match.value.decryptWith;
|
|
101
|
+
if (reason === 'TARGETING_MATCH' || reason === 'SPLIT') {
|
|
102
|
+
result.ruleIndex = match.ruleIndex;
|
|
103
|
+
}
|
|
104
|
+
if (reason === 'SPLIT') {
|
|
105
|
+
result.weightedValueIndex = match.weightedValueIndex;
|
|
106
|
+
}
|
|
107
|
+
evaluations[cfg.key] = result;
|
|
108
|
+
}
|
|
109
|
+
const envelope = {
|
|
110
|
+
evaluations,
|
|
111
|
+
meta: { version: snap.version, environment: snap.environment },
|
|
112
|
+
};
|
|
113
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
114
|
+
res.end(JSON.stringify(envelope));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* qfg-38sf.7: coerce `int`/`double` values to JS numbers so the envelope emits
|
|
118
|
+
* JSON numbers, not JSON strings.
|
|
119
|
+
*
|
|
120
|
+
* Config files (and integration-test-data) store numeric values as JSON
|
|
121
|
+
* strings — e.g. `{"type": "int", "value": "5"}`. api-delivery's
|
|
122
|
+
* `Value.UnmarshalJSON` (api-delivery/internal/config/types.go:182-215) coerces
|
|
123
|
+
* `int` via `strconv.ParseInt` and `double` via `strconv.ParseFloat` at config
|
|
124
|
+
* *load* time, so its EvalEnvelope emits JSON numbers. sdk-node's datadir
|
|
125
|
+
* loader does no such coercion and passes the value through verbatim, so
|
|
126
|
+
* `match.value.value` arrives here as a string. We coerce on output to match
|
|
127
|
+
* api-delivery — a browser SDK consumer doing arithmetic on an integer flag
|
|
128
|
+
* would otherwise get string-concatenation behavior.
|
|
129
|
+
*
|
|
130
|
+
* Discriminate on `valueType` — the value's *own* type, the same field
|
|
131
|
+
* api-delivery switches on (`raw.Type`). This is correct even when the matched
|
|
132
|
+
* value's type differs from the config's declared `valueType` (e.g. a
|
|
133
|
+
* weighted-values config resolving to an `int` arm). ONLY `int` and `double`
|
|
134
|
+
* are coerced; `string`, `bool`, `string_list`, `json`, `provided`, etc. pass
|
|
135
|
+
* through untouched. Integration-test-data's largest numeric value is ~2.5e12,
|
|
136
|
+
* far below Number.MAX_SAFE_INTEGER, so `Number(...)` is lossless here.
|
|
137
|
+
*/
|
|
138
|
+
function coerceNumeric(valueType, value) {
|
|
139
|
+
if ((valueType === 'int' || valueType === 'double') && typeof value === 'string') {
|
|
140
|
+
const n = Number(value);
|
|
141
|
+
return Number.isNaN(n) ? value : n;
|
|
142
|
+
}
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
function resolutionReason(cfg, _match) {
|
|
146
|
+
if (cfg.valueType === 'weighted_values')
|
|
147
|
+
return 'SPLIT';
|
|
148
|
+
if (hasResolutionTargeting(cfg))
|
|
149
|
+
return 'TARGETING_MATCH';
|
|
150
|
+
return 'STATIC';
|
|
151
|
+
}
|
|
152
|
+
function hasResolutionTargeting(cfg) {
|
|
153
|
+
if (rulesAreTargeted(cfg.default?.rules))
|
|
154
|
+
return true;
|
|
155
|
+
const envRules = cfg.environment?.rules;
|
|
156
|
+
if (rulesAreTargeted(envRules))
|
|
157
|
+
return true;
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
function rulesAreTargeted(rules) {
|
|
161
|
+
if (!rules)
|
|
162
|
+
return false;
|
|
163
|
+
for (const rule of rules) {
|
|
164
|
+
for (const criterion of rule.criteria) {
|
|
165
|
+
if (criterion.operator !== 'ALWAYS_TRUE')
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP server for `qfg serve`. Boots a single `@quonfig/node` Quonfig client
|
|
3
|
+
* with `{datadir, dataDirAutoReload}` so file changes flow into a shared
|
|
4
|
+
* snapshot (mirror of the SDK's `ConfigStore`). All requests evaluate against
|
|
5
|
+
* that snapshot — see plan project/plans/qfg-serve.md §5 ("Integration with
|
|
6
|
+
* auto-reload work").
|
|
7
|
+
*
|
|
8
|
+
* Bound by default to 127.0.0.1. Auth and CORS are configured per plan §4.
|
|
9
|
+
*/
|
|
10
|
+
import type { ConfigResponse } from '@quonfig/node';
|
|
11
|
+
export interface Logger {
|
|
12
|
+
log: (msg: string) => void;
|
|
13
|
+
warn: (msg: string) => void;
|
|
14
|
+
}
|
|
15
|
+
export interface ServeOptions {
|
|
16
|
+
allowNonLoopback: boolean;
|
|
17
|
+
corsOrigins: string[];
|
|
18
|
+
datadir: string;
|
|
19
|
+
environment: string;
|
|
20
|
+
/**
|
|
21
|
+
* Test-only escape hatch — startServer treats this string as the bind host
|
|
22
|
+
* for the loopback / non-loopback policy check, while still actually
|
|
23
|
+
* binding to `host`. Lets unit tests assert the WARN path without binding
|
|
24
|
+
* 0.0.0.0 in CI (which some networks forbid). NOT a public option.
|
|
25
|
+
*/
|
|
26
|
+
forceNonLoopbackForTest?: string;
|
|
27
|
+
frontendSdkKey?: string;
|
|
28
|
+
host: string;
|
|
29
|
+
logger: Logger;
|
|
30
|
+
port: number;
|
|
31
|
+
verbose: boolean;
|
|
32
|
+
watch: boolean;
|
|
33
|
+
/** Override the SDK's 200ms default debounce; lets tests check the watch path quickly. */
|
|
34
|
+
watchDebounceMs?: number;
|
|
35
|
+
}
|
|
36
|
+
export interface ServeHandle {
|
|
37
|
+
/** Stop watching, close all sockets, await SDK close. Idempotent. */
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
host: string;
|
|
40
|
+
port: number;
|
|
41
|
+
}
|
|
42
|
+
export declare function startServer(opts: ServeOptions): Promise<ServeHandle>;
|
|
43
|
+
/**
|
|
44
|
+
* G8 (qfg-38sf.6): compute meta.version as sha256 of the canonicalized
|
|
45
|
+
* configs array. Canonical form is `JSON.stringify(configs, sortedKeys)` so
|
|
46
|
+
* two snapshots with identical config content produce the same hash regardless
|
|
47
|
+
* of object-key order. The hash replaces the prior `datadir:<abs-path>#<ts>`
|
|
48
|
+
* shape, which leaked operator filesystem layout and changed on every reload
|
|
49
|
+
* even when the data was unchanged.
|
|
50
|
+
*
|
|
51
|
+
* Exported for unit tests that want to assert determinism / stability.
|
|
52
|
+
*/
|
|
53
|
+
export declare function computeVersion(configs: ConfigResponse[]): string;
|