@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.
@@ -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
@@ -19,7 +19,6 @@ export const initQuonfig = async (_ctx, flagsOrDatafile) => {
19
19
  quonfig = new Quonfig({
20
20
  sdkKey,
21
21
  collectEvaluationSummaries: false,
22
- collectLoggerCounts: false,
23
22
  contextUploadMode: 'none',
24
23
  datafile,
25
24
  enableSSE: false,
@@ -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;