@quonfig/cli 0.0.52 → 0.0.54

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.
@@ -0,0 +1,141 @@
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: 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
+ function resolutionReason(cfg, _match) {
117
+ if (cfg.valueType === 'weighted_values')
118
+ return 'SPLIT';
119
+ if (hasResolutionTargeting(cfg))
120
+ return 'TARGETING_MATCH';
121
+ return 'STATIC';
122
+ }
123
+ function hasResolutionTargeting(cfg) {
124
+ if (rulesAreTargeted(cfg.default?.rules))
125
+ return true;
126
+ const envRules = cfg.environment?.rules;
127
+ if (rulesAreTargeted(envRules))
128
+ return true;
129
+ return false;
130
+ }
131
+ function rulesAreTargeted(rules) {
132
+ if (!rules)
133
+ return false;
134
+ for (const rule of rules) {
135
+ for (const criterion of rule.criteria) {
136
+ if (criterion.operator !== 'ALWAYS_TRUE')
137
+ return true;
138
+ }
139
+ }
140
+ return false;
141
+ }
@@ -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;
@@ -0,0 +1,355 @@
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 * as crypto from 'node:crypto';
11
+ import * as fs from 'node:fs';
12
+ import * as http from 'node:http';
13
+ import { ConfigStore, Evaluator, Quonfig } from '@quonfig/node';
14
+ import { handleEvalContext } from './handlers/evalContext.js';
15
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
16
+ const isLoopback = (host) => LOOPBACK_HOSTS.has(host);
17
+ export async function startServer(opts) {
18
+ // Loopback policy — plan §4 auth note. Refuse non-loopback binds without
19
+ // an explicit opt-in; this prevents the developer from accidentally
20
+ // exposing an unauthenticated server to their LAN.
21
+ const policyHost = opts.forceNonLoopbackForTest ?? opts.host;
22
+ if (!isLoopback(policyHost) && !opts.allowNonLoopback) {
23
+ throw new Error(`Refusing to bind ${opts.host}: pass --allow-non-loopback to confirm the server should be reachable from the LAN.`);
24
+ }
25
+ if (!isLoopback(policyHost) && opts.allowNonLoopback) {
26
+ opts.logger.warn(`qfg serve is bound to ${opts.host}: any device on your LAN can reach it. ` +
27
+ `Anyone with the URL gets the datadir contents; do not run this on an untrusted network ` +
28
+ `without --frontend-sdk-key.`);
29
+ }
30
+ // Canonicalize the datadir to its long-name absolute path before handing
31
+ // it to the SDK. On Windows, os.tmpdir() and short-name paths like
32
+ // `C:\Users\RUNNER~1\...` trip a libuv assertion in fs-event.c when
33
+ // fs.watch compares the registered dir against incoming event filenames
34
+ // (the input is the short name, the events come back as long names).
35
+ // realpathSync.native delegates to uv_fs_realpath, which returns the same
36
+ // canonical form libuv uses internally — so the assertion never fires.
37
+ // No-op on POSIX where short names don't exist.
38
+ const datadir = (() => {
39
+ try {
40
+ return fs.realpathSync.native(opts.datadir);
41
+ }
42
+ catch {
43
+ return opts.datadir;
44
+ }
45
+ })();
46
+ // The Quonfig client owns datadir loading + file watching via the
47
+ // `dataDirAutoReload` option that landed in sdk-node 0.0.30 (qfg-zx3y).
48
+ // We hold a parallel ConfigStore + Evaluator that mirror the client's
49
+ // internal state so the eval handler can call evaluator.evaluateConfig()
50
+ // directly — Quonfig's public surface (`get`, `rawConfig`, `getRawMatch`)
51
+ // doesn't return the typed Value object the EvalEnvelope shape needs.
52
+ let store = new ConfigStore();
53
+ let evaluator = new Evaluator(store);
54
+ const environment = opts.environment;
55
+ let version = '';
56
+ const refreshSnapshot = (client) => {
57
+ // Pull every config out of the client and stuff it into our parallel
58
+ // store. `keys()` and `rawConfig()` are public; they return the
59
+ // ConfigResponse exactly as the client loaded it from disk.
60
+ //
61
+ // NOTE: the SDK fires `onConfigUpdate` from inside its initial
62
+ // `loadLocalData()` call, BEFORE `init()` has flipped `initialized` to
63
+ // true. Calling `client.keys()` during that window would throw "Not
64
+ // initialized." We swallow that one case here — the post-init manual
65
+ // refresh covers the initial load.
66
+ let keys;
67
+ try {
68
+ keys = client.keys();
69
+ }
70
+ catch {
71
+ return;
72
+ }
73
+ const configs = [];
74
+ for (const key of keys) {
75
+ const cfg = client.rawConfig(key);
76
+ if (cfg)
77
+ configs.push(cfg);
78
+ }
79
+ // G8 (qfg-38sf.6): version is a content hash of the canonicalized
80
+ // configs, not the absolute datadir path. The old shape leaked operator
81
+ // filesystem layout on every response and changed on every reload even
82
+ // when the data was unchanged, defeating ETag-style caching.
83
+ version = computeVersion(configs);
84
+ const envelope = {
85
+ configs,
86
+ meta: { version, environment },
87
+ };
88
+ store = new ConfigStore();
89
+ evaluator = new Evaluator(store);
90
+ store.loadFromDatafile(envelope);
91
+ };
92
+ // qfg-zx3y's auto-reload fires `onConfigUpdate` after the SDK has installed
93
+ // the new envelope. We re-sync our snapshot from the client at that point.
94
+ const client = new Quonfig({
95
+ sdkKey: 'qfg-serve-local',
96
+ datadir,
97
+ environment: opts.environment,
98
+ dataDirAutoReload: opts.watch,
99
+ dataDirAutoReloadDebounceMs: opts.watchDebounceMs,
100
+ // Telemetry is intentionally OFF — qfg serve has no Quonfig backend to
101
+ // post to, and the SDK's default-on collectors would just buffer forever.
102
+ collectEvaluationSummaries: false,
103
+ contextUploadMode: 'none',
104
+ enableSSE: false,
105
+ fallbackPollEnabled: false,
106
+ onConfigUpdate() {
107
+ refreshSnapshot(client);
108
+ if (opts.verbose)
109
+ opts.logger.log(`qfg-serve: reloaded datadir (version=${version})`);
110
+ },
111
+ });
112
+ await client.init();
113
+ // Initial snapshot — the SDK fires onConfigUpdate during init() so this is
114
+ // mostly insurance against an init path that doesn't, but it's cheap.
115
+ if (store.keys().length === 0) {
116
+ refreshSnapshot(client);
117
+ }
118
+ // CORS resolution per request. Three shapes:
119
+ // - `*` anywhere in the list → wildcard (no Vary)
120
+ // - exactly one origin → fixed value (no Vary; caller pinned it)
121
+ // - 2+ origins → allow-list: echo the request Origin if it matches and emit
122
+ // `Vary: Origin`; omit the header entirely on a miss (qfg-38sf.5). The
123
+ // CORS spec does not permit a comma-joined Access-Control-Allow-Origin,
124
+ // so the previous `.join(', ')` was silently breaking browser clients.
125
+ const corsAllowList = new Set(opts.corsOrigins);
126
+ const corsMode = corsAllowList.has('*')
127
+ ? 'wildcard'
128
+ : opts.corsOrigins.length <= 1
129
+ ? 'fixed'
130
+ : 'allowlist';
131
+ const fixedCorsOrigin = corsMode === 'fixed' ? (opts.corsOrigins[0] ?? '*') : null;
132
+ // qfg-38sf.1 security audit F3: the sendToClientSdk filter is UNCONDITIONAL.
133
+ // Every qfg serve consumer is by definition a frontend client (browser /
134
+ // react-native — server SDKs use datadir mode directly). There is no flag
135
+ // that turns this off. See evalContext.ts for the filter itself.
136
+ // Bump maxHeaderSize so that oversized base64 context tokens reach our
137
+ // application-level 414 handler rather than getting bounced by Node's
138
+ // default 16KB header parser. Our app cap is 64KB (see evalContext.ts G6
139
+ // check); 256KB leaves room for the path plus normal Authorization /
140
+ // Cookie / Accept headers.
141
+ const server = http.createServer({ maxHeaderSize: 256 * 1024 }, async (req, res) => {
142
+ const startedAt = Date.now();
143
+ // CORS — emit on every response, including 401 and 404, so browser
144
+ // clients surface the real status code instead of an opaque CORS error.
145
+ if (corsMode === 'wildcard') {
146
+ res.setHeader('Access-Control-Allow-Origin', '*');
147
+ }
148
+ else if (corsMode === 'fixed' && fixedCorsOrigin !== null) {
149
+ res.setHeader('Access-Control-Allow-Origin', fixedCorsOrigin);
150
+ }
151
+ else if (corsMode === 'allowlist') {
152
+ const reqOrigin = req.headers.origin;
153
+ // Vary: Origin regardless of match — caches must not reuse a
154
+ // matched-Origin response for a request from a different Origin.
155
+ res.setHeader('Vary', 'Origin');
156
+ if (typeof reqOrigin === 'string' && corsAllowList.has(reqOrigin)) {
157
+ res.setHeader('Access-Control-Allow-Origin', reqOrigin);
158
+ }
159
+ }
160
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
161
+ res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
162
+ res.setHeader('Access-Control-Expose-Headers', 'ETag');
163
+ if (req.method === 'OPTIONS') {
164
+ res.writeHead(204);
165
+ res.end();
166
+ logRequest(req, res, startedAt, opts);
167
+ return;
168
+ }
169
+ // Auth — plan §4. If --frontend-sdk-key was set, every non-OPTIONS
170
+ // request must present `Authorization: Basic base64("1:<key>")`. If it
171
+ // wasn't set, no check.
172
+ if (opts.frontendSdkKey) {
173
+ const ok = checkBasicAuth(req.headers.authorization, opts.frontendSdkKey);
174
+ if (!ok) {
175
+ res.writeHead(401, {
176
+ 'Content-Type': 'text/plain',
177
+ 'WWW-Authenticate': 'Basic realm="qfg-serve"',
178
+ });
179
+ res.end('unauthorized');
180
+ logRequest(req, res, startedAt, opts);
181
+ return;
182
+ }
183
+ }
184
+ const url = req.url ?? '/';
185
+ const [pathOnly] = url.split('?');
186
+ // GET /api/v2/configs/eval-with-context/{base64url}
187
+ const evalPrefix = '/api/v2/configs/eval-with-context/';
188
+ if (req.method === 'GET' && pathOnly.startsWith(evalPrefix)) {
189
+ const ctxToken = pathOnly.slice(evalPrefix.length);
190
+ if (!ctxToken) {
191
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
192
+ res.end('missing context token');
193
+ logRequest(req, res, startedAt, opts);
194
+ return;
195
+ }
196
+ try {
197
+ await handleEvalContext(req, res, ctxToken, {
198
+ getSnapshot: () => ({ store, evaluator, environment, version }),
199
+ });
200
+ }
201
+ catch (error) {
202
+ opts.logger.warn(`qfg-serve: eval-with-context handler threw: ${error.message}`);
203
+ if (!res.headersSent) {
204
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
205
+ res.end('internal error');
206
+ }
207
+ }
208
+ logRequest(req, res, startedAt, opts);
209
+ return;
210
+ }
211
+ // POST /api/v1/telemetry/ → 404 with a helpful body. Plan §2: we
212
+ // intentionally don't accept telemetry — silently 200-OK-and-dropping
213
+ // would hide a misconfig.
214
+ if (req.method === 'POST' && pathOnly === '/api/v1/telemetry/') {
215
+ res.writeHead(404, { 'Content-Type': 'application/json' });
216
+ res.end(JSON.stringify({
217
+ error: 'qfg serve does not accept telemetry',
218
+ hint: 'Disable client-side telemetry by passing `collectEvaluationSummaries: false` ' +
219
+ '(and `contextUploadMode: "none"`) to your SDK init, or point the SDK\'s ' +
220
+ 'telemetryUrl at a real Quonfig telemetry endpoint.',
221
+ }));
222
+ logRequest(req, res, startedAt, opts);
223
+ return;
224
+ }
225
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
226
+ res.end('not found');
227
+ logRequest(req, res, startedAt, opts);
228
+ });
229
+ // Bind. EADDRINUSE is the only common failure here; surface it with a
230
+ // pointed message so the developer doesn't have to read a stack trace.
231
+ await new Promise((resolve, reject) => {
232
+ const onError = (err) => {
233
+ server.removeListener('listening', onListening);
234
+ if (err.code === 'EADDRINUSE') {
235
+ reject(new Error(`Port ${opts.port} is already in use. Pass --port <n> to pick another.`));
236
+ }
237
+ else {
238
+ reject(err);
239
+ }
240
+ };
241
+ const onListening = () => {
242
+ server.removeListener('error', onError);
243
+ resolve();
244
+ };
245
+ server.once('error', onError);
246
+ server.once('listening', onListening);
247
+ server.listen(opts.port, opts.host);
248
+ });
249
+ const boundPort = server.address().port;
250
+ let closed = false;
251
+ const close = async () => {
252
+ if (closed)
253
+ return;
254
+ closed = true;
255
+ await new Promise((resolve) => {
256
+ server.close(() => resolve());
257
+ });
258
+ try {
259
+ await client.close();
260
+ }
261
+ catch {
262
+ /* best effort */
263
+ }
264
+ };
265
+ return { port: boundPort, host: opts.host, close };
266
+ }
267
+ /**
268
+ * G8 (qfg-38sf.6): compute meta.version as sha256 of the canonicalized
269
+ * configs array. Canonical form is `JSON.stringify(configs, sortedKeys)` so
270
+ * two snapshots with identical config content produce the same hash regardless
271
+ * of object-key order. The hash replaces the prior `datadir:<abs-path>#<ts>`
272
+ * shape, which leaked operator filesystem layout and changed on every reload
273
+ * even when the data was unchanged.
274
+ *
275
+ * Exported for unit tests that want to assert determinism / stability.
276
+ */
277
+ export function computeVersion(configs) {
278
+ const canonical = JSON.stringify(configs, sortedKeyReplacer);
279
+ return `sha256:${crypto.createHash('sha256').update(canonical, 'utf8').digest('hex')}`;
280
+ }
281
+ /**
282
+ * Replacer for JSON.stringify that emits object keys in sorted order. Arrays
283
+ * keep their original order (array order is semantically meaningful for rules
284
+ * and weighted-values).
285
+ */
286
+ function sortedKeyReplacer(_key, value) {
287
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
288
+ const sorted = {};
289
+ for (const k of Object.keys(value).sort()) {
290
+ sorted[k] = value[k];
291
+ }
292
+ return sorted;
293
+ }
294
+ return value;
295
+ }
296
+ /**
297
+ * Validate `Authorization: Basic base64("1:<key>")` against the configured
298
+ * SDK key. The browser SDKs build this header in
299
+ * `sdk-javascript/src/apiHelpers.ts:7`.
300
+ *
301
+ * G4: use `crypto.timingSafeEqual` instead of `===`. The frontend SDK key is
302
+ * publicly-available by design (plan §4) so this isn't a hard security
303
+ * boundary, but `timingSafeEqual` is the right default — cheap, correct, and
304
+ * removes a class of timing-leak nits from any future audit.
305
+ *
306
+ * Length check first because `timingSafeEqual` throws on mismatched buffer
307
+ * lengths; that throw is itself a timing side-channel (and an exception path).
308
+ */
309
+ function checkBasicAuth(header, expectedKey) {
310
+ if (!header || !header.toLowerCase().startsWith('basic '))
311
+ return false;
312
+ const b64 = header.slice('basic '.length).trim();
313
+ let decoded;
314
+ try {
315
+ decoded = Buffer.from(b64, 'base64').toString('utf8');
316
+ }
317
+ catch {
318
+ return false;
319
+ }
320
+ const expected = `1:${expectedKey}`;
321
+ const decodedBuf = Buffer.from(decoded, 'utf8');
322
+ const expectedBuf = Buffer.from(expected, 'utf8');
323
+ if (decodedBuf.length !== expectedBuf.length)
324
+ return false;
325
+ return crypto.timingSafeEqual(decodedBuf, expectedBuf);
326
+ }
327
+ /**
328
+ * Verbose-mode request log. G1 (qfg-38sf.1 audit): redact the base64-encoded
329
+ * context token from the URL — it contains caller PII (email, user id,
330
+ * arbitrary attributes). Format matches api-delivery's LoggingMiddleware
331
+ * shape: `METHOD PATH STATUS DURATION_MS`. We elide the ctx segment so the
332
+ * path collapses to e.g.
333
+ * `GET /api/v2/configs/eval-with-context/<ctx-redacted> 200 3ms`
334
+ * which still tells the developer which route fired without leaking the
335
+ * payload.
336
+ */
337
+ function logRequest(req, res, startedAt, opts) {
338
+ if (!opts.verbose)
339
+ return;
340
+ const method = req.method ?? '-';
341
+ const rawUrl = req.url ?? '/';
342
+ const [pathOnly, query] = rawUrl.split('?', 2);
343
+ const evalPrefix = '/api/v2/configs/eval-with-context/';
344
+ let safePath = pathOnly;
345
+ if (pathOnly.startsWith(evalPrefix)) {
346
+ safePath = `${evalPrefix}<ctx-redacted>`;
347
+ }
348
+ // Drop query string entirely — collectContextMode and friends are fine to
349
+ // surface but we have no allowlist parser today, so be conservative.
350
+ if (query && query.length > 0)
351
+ safePath += '?<redacted>';
352
+ const status = res.statusCode;
353
+ const duration = Date.now() - startedAt;
354
+ opts.logger.log(`qfg-serve: ${method} ${safePath} ${status} ${duration}ms`);
355
+ }
@@ -4,7 +4,13 @@ export interface FrictionEntry {
4
4
  exitCode: number;
5
5
  ts: string;
6
6
  }
7
- export declare function getDefaultFrictionLogPath(home?: string): string;
7
+ /**
8
+ * Default friction-log location: a `friction.log` file inside the shared
9
+ * Quonfig config home (`~/.quonfig`, the same dir as tokens/profile config).
10
+ * Honors `QUONFIG_CONFIG_HOME` via `getQuonfigConfigHome()` so tests and
11
+ * isolated environments redirect it along with everything else.
12
+ */
13
+ export declare function getDefaultFrictionLogPath(configHome?: string): string;
8
14
  export declare function getFrictionLogPath(envValue?: string | undefined, cwd?: string): null | string;
9
15
  export declare function buildFrictionEntry(opts: {
10
16
  argv?: string[];
@@ -1,8 +1,14 @@
1
1
  import fs from 'node:fs';
2
- import os from 'node:os';
3
2
  import path from 'node:path';
4
- export function getDefaultFrictionLogPath(home = os.homedir()) {
5
- return path.join(home, '.qfg', 'friction.log');
3
+ import { getQuonfigConfigHome } from './quonfig-home.js';
4
+ /**
5
+ * Default friction-log location: a `friction.log` file inside the shared
6
+ * Quonfig config home (`~/.quonfig`, the same dir as tokens/profile config).
7
+ * Honors `QUONFIG_CONFIG_HOME` via `getQuonfigConfigHome()` so tests and
8
+ * isolated environments redirect it along with everything else.
9
+ */
10
+ export function getDefaultFrictionLogPath(configHome = getQuonfigConfigHome()) {
11
+ return path.join(configHome, 'friction.log');
6
12
  }
7
13
  export function getFrictionLogPath(envValue, cwd = process.cwd()) {
8
14
  if (envValue === undefined)
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const _default: "0.0.52";
1
+ declare const _default: "0.0.54";
2
2
  export default _default;
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // AUTO-GENERATED from package.json by scripts/generate-version.mjs — do not edit.
2
- export default '0.0.52';
2
+ export default '0.0.54';