quonfig 0.0.8 → 0.0.9

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 18707780a8ad33c299973f4de01bc0b76d10161902f977b9771c10c2d4a23fde
4
- data.tar.gz: 6980b904632659b97f16636e9a317bc8b3e8cbf02cc0b8f75ecf51325b8ba472
3
+ metadata.gz: e2dfc7385d529233f3b0b7c5fc13dbe7d0d648851ea0815bde792465a1774319
4
+ data.tar.gz: f6c702ba4eccdebeda95b8f9341fc0bb077a2c141c9397cb7f3dccb1cbfa782a
5
5
  SHA512:
6
- metadata.gz: 74c6a59b81fd222ddd522e52301ea74a1f9bc76b18ecbfe50c01a46e21539bdc5cea6a38a927938fb497f6003a64a2e796d177f2ed3ee527d5891f6cd9ca63a4
7
- data.tar.gz: 94b1c109e6db3df2ea115e62f80f099430752779faa9655ee6f53c20c614fa42ccdc8e350342985cb09861e286bd91b857381508849a81fada865927c98f7f22
6
+ metadata.gz: 368273026e247c7c01df6e85f11865f94bb588d4c4e512bbc44de65dda0a7659b96a8bd321de6ea676934b9483b0dd99d0fe516d3b288cf93a709f401bbfd824
7
+ data.tar.gz: be80686fa0c8748c4e1c3f802cfc51c605b1a3e31ec7f9682885a139b5081a79d91d9155833143671bfffa75728bf1091116456defd90bd610d17a5133287e03
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.0.8
1
+ 0.0.9
@@ -40,6 +40,32 @@ module Quonfig
40
40
  @client.get_json(key, default: default, context: @context)
41
41
  end
42
42
 
43
+ # ---- Details getters ----------------------------------------------
44
+
45
+ def get_bool_details(key)
46
+ @client.get_bool_details(key, context: @context)
47
+ end
48
+
49
+ def get_string_details(key)
50
+ @client.get_string_details(key, context: @context)
51
+ end
52
+
53
+ def get_int_details(key)
54
+ @client.get_int_details(key, context: @context)
55
+ end
56
+
57
+ def get_float_details(key)
58
+ @client.get_float_details(key, context: @context)
59
+ end
60
+
61
+ def get_string_list_details(key)
62
+ @client.get_string_list_details(key, context: @context)
63
+ end
64
+
65
+ def get_json_details(key)
66
+ @client.get_json_details(key, context: @context)
67
+ end
68
+
43
69
  def enabled?(feature_name)
44
70
  @client.enabled?(feature_name, @context)
45
71
  end
@@ -32,7 +32,7 @@ module Quonfig
32
32
  else
33
33
  Quonfig::Options.new(option_kwargs)
34
34
  end
35
- @global_context = normalize_context(@options.global_context)
35
+ @global_context = build_initial_global_context(@options)
36
36
  @instance_hash = SecureRandom.uuid
37
37
  @store = store || Quonfig::ConfigStore.new
38
38
  @evaluator = Quonfig::Evaluator.new(@store, env_id: @options.environment)
@@ -103,6 +103,38 @@ module Quonfig
103
103
  typed_get(key, :json, default: default, context: context)
104
104
  end
105
105
 
106
+ # ---- Details getters ----------------------------------------------
107
+ #
108
+ # Mirrors the typed getters above but returns a +Quonfig::EvaluationDetails+
109
+ # carrying the OpenFeature-aligned resolution +reason+ ("STATIC",
110
+ # "TARGETING_MATCH", "SPLIT", "DEFAULT", or "ERROR") plus an
111
+ # +error_code+/+error_message+ on the error path. These methods never
112
+ # raise — exceptions are caught and rendered as ERROR details.
113
+
114
+ def get_bool_details(key, context: NO_DEFAULT_PROVIDED)
115
+ evaluate_details(key, :bool, context)
116
+ end
117
+
118
+ def get_string_details(key, context: NO_DEFAULT_PROVIDED)
119
+ evaluate_details(key, String, context)
120
+ end
121
+
122
+ def get_int_details(key, context: NO_DEFAULT_PROVIDED)
123
+ evaluate_details(key, Integer, context)
124
+ end
125
+
126
+ def get_float_details(key, context: NO_DEFAULT_PROVIDED)
127
+ evaluate_details(key, Float, context)
128
+ end
129
+
130
+ def get_string_list_details(key, context: NO_DEFAULT_PROVIDED)
131
+ evaluate_details(key, :string_list, context)
132
+ end
133
+
134
+ def get_json_details(key, context: NO_DEFAULT_PROVIDED)
135
+ evaluate_details(key, :json, context)
136
+ end
137
+
106
138
  def enabled?(feature_name, jit_context = NO_DEFAULT_PROVIDED)
107
139
  value = get(feature_name, false, jit_context)
108
140
  value == true || value == 'true'
@@ -314,7 +346,7 @@ module Quonfig
314
346
  config_key: config_field(config, :key),
315
347
  config_type: config_field(config, :type),
316
348
  conditional_value_index: result.rule_index,
317
- weighted_value_index: nil,
349
+ weighted_value_index: result.weighted_value_index,
318
350
  selected_value: result.unwrapped_value,
319
351
  reason: result.wire_reason
320
352
  )
@@ -441,6 +473,21 @@ module Quonfig
441
473
  merge_contexts(@global_context, jit)
442
474
  end
443
475
 
476
+ # Combine the customer-supplied globalContext with the optional dev
477
+ # context loaded from ~/.quonfig/tokens.json. Dev context goes UNDER the
478
+ # customer's so any explicit `quonfig-user` keys win on collision.
479
+ def build_initial_global_context(options)
480
+ customer = normalize_context(options.global_context)
481
+ enabled = options.enable_quonfig_user_context == true ||
482
+ ENV['QUONFIG_DEV_CONTEXT'] == 'true'
483
+ return customer unless enabled
484
+
485
+ dev = Quonfig::DevContext.load_quonfig_user_context
486
+ return customer if dev.nil?
487
+
488
+ merge_contexts(dev, customer)
489
+ end
490
+
444
491
  def normalize_context(ctx)
445
492
  return {} if ctx.nil?
446
493
  return ctx if ctx.is_a?(Hash)
@@ -485,6 +532,61 @@ module Quonfig
485
532
  nil
486
533
  end
487
534
 
535
+ # Build a Quonfig::EvaluationDetails for +key+, evaluated against the
536
+ # caller's context, after coercing/checking +expected_type+. Never
537
+ # raises; all exceptions become ERROR details.
538
+ def evaluate_details(key, expected_type, context)
539
+ jit = context == NO_DEFAULT_PROVIDED ? nil : context
540
+ ctx = build_context(jit)
541
+ record_context_for_telemetry(ctx)
542
+
543
+ result =
544
+ begin
545
+ @resolver.get(key, ctx)
546
+ rescue Quonfig::Errors::MissingDefaultError => e
547
+ return Quonfig::EvaluationDetails.new(
548
+ value: nil,
549
+ reason: Quonfig::EvaluationDetails::REASON_ERROR,
550
+ error_code: Quonfig::EvaluationDetails::ERROR_FLAG_NOT_FOUND,
551
+ error_message: e.message
552
+ )
553
+ end
554
+
555
+ if result.nil?
556
+ return Quonfig::EvaluationDetails.new(
557
+ value: nil,
558
+ reason: Quonfig::EvaluationDetails::REASON_DEFAULT
559
+ )
560
+ end
561
+
562
+ record_evaluation_for_telemetry(result)
563
+
564
+ raw_value = result.unwrapped_value
565
+
566
+ begin
567
+ coerced = coerce_and_check(key, raw_value, expected_type) unless raw_value.nil?
568
+ rescue Quonfig::Errors::TypeMismatchError => e
569
+ return Quonfig::EvaluationDetails.new(
570
+ value: nil,
571
+ reason: Quonfig::EvaluationDetails::REASON_ERROR,
572
+ error_code: Quonfig::EvaluationDetails::ERROR_TYPE_MISMATCH,
573
+ error_message: e.message
574
+ )
575
+ end
576
+
577
+ Quonfig::EvaluationDetails.new(
578
+ value: coerced,
579
+ reason: result.of_reason
580
+ )
581
+ rescue StandardError => e
582
+ Quonfig::EvaluationDetails.new(
583
+ value: nil,
584
+ reason: Quonfig::EvaluationDetails::REASON_ERROR,
585
+ error_code: Quonfig::EvaluationDetails::ERROR_GENERAL,
586
+ error_message: e.message
587
+ )
588
+ end
589
+
488
590
  def typed_get(key, expected_type, default:, context:)
489
591
  jit = context == NO_DEFAULT_PROVIDED ? NO_DEFAULT_PROVIDED : context
490
592
  value = get(key, default, jit)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Quonfig
6
+ # Dev-only context loader. Reads ~/.quonfig/tokens.json (written by
7
+ # `qfg login`) and returns {'quonfig-user' => {'email' => ...}} when a
8
+ # userEmail is present. Returns nil when the file is missing, unreadable,
9
+ # or has no userEmail.
10
+ #
11
+ # The attribute is dev-only by construction: production servers do not
12
+ # run `qfg login` and therefore have no tokens file. Rules keyed on
13
+ # `quonfig-user.email` are dead code in prod.
14
+ module DevContext
15
+ TOKENS_BASENAME = File.join('.quonfig', 'tokens.json')
16
+
17
+ def self.load_quonfig_user_context
18
+ path = File.join(Dir.home, TOKENS_BASENAME)
19
+ return nil unless File.exist?(path)
20
+
21
+ raw = begin
22
+ File.read(path)
23
+ rescue StandardError => e
24
+ warn "[quonfig] dev-context: could not read #{path} (#{e.class}: #{e.message}); skipping injection"
25
+ return nil
26
+ end
27
+
28
+ parsed = begin
29
+ JSON.parse(raw)
30
+ rescue JSON::ParserError => e
31
+ warn "[quonfig] dev-context: could not parse #{path} (#{e.message}); skipping injection"
32
+ return nil
33
+ end
34
+
35
+ email = parsed.is_a?(Hash) ? parsed['userEmail'] : nil
36
+ return nil unless email.is_a?(String) && !email.empty?
37
+
38
+ { 'quonfig-user' => { 'email' => email } }
39
+ end
40
+ end
41
+ end
@@ -416,14 +416,20 @@ module Quonfig
416
416
  REASON_TARGETING_MATCH = 2
417
417
  REASON_SPLIT = 3
418
418
 
419
- attr_reader :value, :rule_index, :config
419
+ attr_reader :value, :rule_index, :config, :reportable_value
420
420
  attr_accessor :weighted_value_index
421
421
 
422
- def initialize(value:, rule_index:, config:, weighted_value_index: nil)
422
+ def initialize(value:, rule_index:, config:, weighted_value_index: nil, reportable_value: nil)
423
423
  @value = value
424
424
  @rule_index = rule_index
425
425
  @config = config
426
426
  @weighted_value_index = weighted_value_index
427
+ # Telemetry-safe substitute for #unwrapped_value. Set by Resolver when
428
+ # the underlying Value was confidential / decryptWith, so callers
429
+ # (the eval-summary aggregator) never see the plaintext. Mirrors
430
+ # ReforgeHQ/sdk-ruby ConfigValueUnwrapper#reportable_value (the
431
+ # `*****<5-md5>` hash form).
432
+ @reportable_value = reportable_value
427
433
  end
428
434
 
429
435
  # Integer reason code for telemetry. Mirrors sdk-node's computeReason:
@@ -436,6 +442,19 @@ module Quonfig
436
442
  REASON_TARGETING_MATCH
437
443
  end
438
444
 
445
+ # OpenFeature-aligned reason string. Same classification logic as
446
+ # +wire_reason+ but as a public string the OF provider (and any
447
+ # third-party consumer of EvaluationDetails) can pass straight through.
448
+ #
449
+ # Returns one of: "STATIC", "TARGETING_MATCH", "SPLIT".
450
+ def of_reason
451
+ case wire_reason
452
+ when REASON_STATIC then EvaluationDetails::REASON_STATIC
453
+ when REASON_SPLIT then EvaluationDetails::REASON_SPLIT
454
+ else EvaluationDetails::REASON_TARGETING_MATCH
455
+ end
456
+ end
457
+
439
458
  # True if any rule on the config (default or environment) has a
440
459
  # non-ALWAYS_TRUE criterion. Used to decide STATIC vs TARGETING_MATCH.
441
460
  def self.targeting_rules?(config)
@@ -21,6 +21,7 @@ module Quonfig
21
21
  attr_reader :poll_interval
22
22
  attr_reader :global_context
23
23
  attr_reader :logger_key
24
+ attr_reader :enable_quonfig_user_context
24
25
  attr_accessor :is_fork
25
26
 
26
27
  module ON_INITIALIZATION_FAILURE
@@ -74,7 +75,8 @@ module Quonfig
74
75
  collect_max_evaluation_summaries: DEFAULT_MAX_EVAL_SUMMARIES,
75
76
  allow_telemetry_in_local_mode: false,
76
77
  global_context: {},
77
- logger_key: nil
78
+ logger_key: nil,
79
+ enable_quonfig_user_context: false
78
80
  )
79
81
  @sdk_key = sdk_key
80
82
  @environment = environment
@@ -94,6 +96,7 @@ module Quonfig
94
96
  @is_fork = false
95
97
  @global_context = global_context
96
98
  @logger_key = logger_key
99
+ @enable_quonfig_user_context = enable_quonfig_user_context
97
100
 
98
101
  # defaults that may be overridden by context_upload_mode
99
102
  @collect_shapes = false
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+
3
5
  module Quonfig
4
6
  # Public-API resolver: looks up a config by key in a ConfigStore and runs
5
7
  # it through an Evaluator against a Context.
@@ -15,6 +17,11 @@ module Quonfig
15
17
  # Quonfig::ConfigResolver — the two coexist during the JSON migration.
16
18
  class Resolver
17
19
  TRUE_VALUES = %w[true 1 t yes].freeze
20
+ # Prefix the eval-summary aggregator stamps onto redacted confidential
21
+ # values before the 5-char MD5 hash. Matches CONFIDENTIAL_PREFIX in
22
+ # ReforgeHQ/sdk-ruby/lib/reforge/config_value_unwrapper.rb so dashboards
23
+ # built against the predecessor wire format keep working.
24
+ CONFIDENTIAL_PREFIX = '*****'
18
25
 
19
26
  attr_reader :store, :evaluator
20
27
  attr_accessor :project_env_id
@@ -50,7 +57,8 @@ module Quonfig
50
57
  value: resolved_value,
51
58
  rule_index: eval_result.rule_index,
52
59
  config: config,
53
- weighted_value_index: weighted_index
60
+ weighted_value_index: weighted_index,
61
+ reportable_value: redacted_reportable_value(eval_result.value)
54
62
  )
55
63
  end
56
64
 
@@ -87,6 +95,26 @@ module Quonfig
87
95
 
88
96
  private
89
97
 
98
+ # If +value+ is confidential or has a decryptWith key, return the
99
+ # `*****<5-hex>` redacted string the eval-summary telemetry aggregator
100
+ # should ship in place of the resolved plaintext. The hash is computed
101
+ # over the raw `value[:value]` (ciphertext when decryptWith is set,
102
+ # plaintext-as-stored when only `confidential: true`) — matches
103
+ # ReforgeHQ/sdk-ruby ConfigValueUnwrapper#reportable_wrapped_value
104
+ # (CONFIDENTIAL_PREFIX + first 5 chars of MD5).
105
+ def redacted_reportable_value(value)
106
+ return nil if value.nil?
107
+
108
+ confidential = vget(value, :confidential, 'confidential')
109
+ decrypt_with = vget(value, :decryptWith, 'decryptWith', :decrypt_with, 'decrypt_with')
110
+ return nil unless confidential || (decrypt_with && !decrypt_with.to_s.empty?)
111
+
112
+ raw = vget(value, :value, 'value')
113
+ return nil if raw.nil?
114
+
115
+ "#{CONFIDENTIAL_PREFIX}#{Digest::MD5.hexdigest(raw.to_s)[0, 5]}"
116
+ end
117
+
90
118
  def vget(hash, *keys)
91
119
  return nil if hash.nil?
92
120
 
@@ -179,7 +207,7 @@ module Quonfig
179
207
  begin
180
208
  plaintext = Quonfig::Encryption.new(secret_key).decrypt(ciphertext)
181
209
  rescue StandardError => e
182
- raise Quonfig::Errors::DecryptionError.new(config_key(config), e.message)
210
+ raise Quonfig::Errors::DecryptionError.new(config_key(config), e.message), cause: e
183
211
  end
184
212
 
185
213
  {
@@ -153,11 +153,23 @@ module Quonfig
153
153
  @at_exit_registered = true
154
154
  end
155
155
 
156
+ # Wait this long for the background reporter thread to exit before
157
+ # giving up. Bounded so a thread blocked on a dead telemetry endpoint
158
+ # can't hang process exit.
159
+ AT_EXIT_THREAD_JOIN_TIMEOUT_SECONDS = 1.0
160
+
156
161
  # Idempotent final drain. Safe to call after #stop has already
157
162
  # drained: aggregators return nil when empty and #sync becomes a
158
- # no-op.
163
+ # no-op. Bounded so a stuck reporter thread or dead telemetry
164
+ # endpoint can't hang process exit.
159
165
  def final_drain_on_exit
160
166
  @stopped.make_true
167
+ thread = @thread
168
+ @thread = nil
169
+ if thread&.alive?
170
+ thread.wakeup
171
+ thread.join(AT_EXIT_THREAD_JOIN_TIMEOUT_SECONDS)
172
+ end
161
173
  sync
162
174
  rescue StandardError => e
163
175
  LOG.debug "[quonfig] at_exit telemetry drain failed: #{e.class}: #{e.message}"
data/lib/quonfig.rb CHANGED
@@ -25,6 +25,7 @@ require 'quonfig/error'
25
25
  require 'quonfig/duration'
26
26
  require 'quonfig/reason'
27
27
  require 'quonfig/evaluation'
28
+ require 'quonfig/evaluation_details'
28
29
  require 'quonfig/encryption'
29
30
  require 'quonfig/exponential_backoff'
30
31
  require 'quonfig/periodic_sync'
@@ -39,6 +40,7 @@ require 'quonfig/errors/decryption_error'
39
40
  require 'quonfig/errors/missing_environment_error'
40
41
  require 'quonfig/errors/invalid_environment_error'
41
42
  require 'quonfig/options'
43
+ require 'quonfig/dev_context'
42
44
  require 'quonfig/rate_limit_cache'
43
45
  require 'quonfig/weighted_value_resolver'
44
46
  require 'quonfig/config_store'
data/quonfig.gemspec CHANGED
@@ -2,16 +2,16 @@
2
2
  # DO NOT EDIT THIS FILE DIRECTLY
3
3
  # Instead, edit Juwelier::Tasks in Rakefile, and run 'rake gemspec'
4
4
  # -*- encoding: utf-8 -*-
5
- # stub: quonfig 0.0.8 ruby lib
5
+ # stub: quonfig 0.0.9 ruby lib
6
6
 
7
7
  Gem::Specification.new do |s|
8
8
  s.name = "quonfig".freeze
9
- s.version = "0.0.8".freeze
9
+ s.version = "0.0.9".freeze
10
10
 
11
11
  s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
12
12
  s.require_paths = ["lib".freeze]
13
13
  s.authors = ["Jeff Dwyer".freeze]
14
- s.date = "2026-04-26"
14
+ s.date = "2026-04-27"
15
15
  s.description = "Quonfig \u2014 feature flags and live config, stored as files in git.".freeze
16
16
  s.email = "jeff@quonfig.com".freeze
17
17
  s.extra_rdoc_files = [
@@ -54,6 +54,7 @@ Gem::Specification.new do |s|
54
54
  "lib/quonfig/config_store.rb",
55
55
  "lib/quonfig/context.rb",
56
56
  "lib/quonfig/datadir.rb",
57
+ "lib/quonfig/dev_context.rb",
57
58
  "lib/quonfig/duration.rb",
58
59
  "lib/quonfig/encryption.rb",
59
60
  "lib/quonfig/error.rb",
@@ -96,6 +97,7 @@ Gem::Specification.new do |s|
96
97
  "test/fixtures/datafile.json",
97
98
  "test/integration/test_context_precedence.rb",
98
99
  "test/integration/test_datadir_environment.rb",
100
+ "test/integration/test_dev_overrides.rb",
99
101
  "test/integration/test_enabled.rb",
100
102
  "test/integration/test_enabled_with_contexts.rb",
101
103
  "test/integration/test_get.rb",
@@ -119,6 +121,7 @@ Gem::Specification.new do |s|
119
121
  "test/test_context_shape.rb",
120
122
  "test/test_context_shape_aggregator.rb",
121
123
  "test/test_datadir.rb",
124
+ "test/test_dev_context.rb",
122
125
  "test/test_duration.rb",
123
126
  "test/test_encryption.rb",
124
127
  "test/test_evaluation_summaries_aggregator.rb",
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # AUTO-GENERATED from integration-test-data/tests/eval/dev_overrides.yaml.
4
+ # Regenerate with:
5
+ # cd integration-test-data/generators && npm run generate -- --target=ruby
6
+ # Source: integration-test-data/generators/src/targets/ruby.ts
7
+ # Do NOT edit by hand — changes will be overwritten.
8
+
9
+ require 'test_helper'
10
+ require 'integration/test_helpers'
11
+
12
+ class TestDevOverrides < Minitest::Test
13
+ def setup
14
+ @store = IntegrationTestHelpers.build_store("dev_overrides")
15
+ end
16
+
17
+ # override fires when quonfig-user.email matches
18
+ def test_override_fires_when_quonfig_user_email_matches
19
+ resolver = IntegrationTestHelpers.build_resolver(@store)
20
+ IntegrationTestHelpers.assert_enabled(resolver, "feature-flag.dev-override", {"quonfig-user" => {"email" => "bob@foo.com"}}, true)
21
+ end
22
+
23
+ # override does not fire when attribute absent (prod simulation)
24
+ def test_override_does_not_fire_when_attribute_absent_prod_simulation
25
+ resolver = IntegrationTestHelpers.build_resolver(@store)
26
+ IntegrationTestHelpers.assert_enabled(resolver, "feature-flag.dev-override", {"user" => {"email" => "bob@foo.com"}}, false)
27
+ end
28
+
29
+ # override matches any email in IS_ONE_OF list
30
+ def test_override_matches_any_email_in_is_one_of_list
31
+ resolver = IntegrationTestHelpers.build_resolver(@store)
32
+ IntegrationTestHelpers.assert_enabled(resolver, "feature-flag.dev-override.multi-email", {"quonfig-user" => {"email" => "alice@foo.com"}}, true)
33
+ end
34
+
35
+ # override beats customer rule by priority
36
+ def test_override_beats_customer_rule_by_priority
37
+ resolver = IntegrationTestHelpers.build_resolver(@store)
38
+ IntegrationTestHelpers.assert_enabled(resolver, "feature-flag.dev-override.priority", {"quonfig-user" => {"email" => "bob@foo.com"}, "user" => {"country" => "DE"}}, true)
39
+ end
40
+ end
@@ -290,6 +290,13 @@ module IntegrationTestHelpers
290
290
  # so eval-summary cases can resolve real values for each key.
291
291
  class << self
292
292
  attr_accessor :last_store
293
+ # Side-channel populated by record_one_eval whenever we redact a
294
+ # confidential value before recording it on the aggregator. Keyed by
295
+ # config_key → { unwrapped:, value_type: }. evaluation_summary_post
296
+ # consults it so the YAML's `value` / `value_type` fields can still
297
+ # assert the runtime resolved value while `selected_value` carries
298
+ # the wire-redacted form.
299
+ attr_accessor :last_unwrapped_overrides
293
300
  end
294
301
 
295
302
  # Construct an aggregator. +kind+ is one of :context_shape,
@@ -465,6 +472,7 @@ module IntegrationTestHelpers
465
472
  ctx = contexts.is_a?(Quonfig::Context) ? contexts : Quonfig::Context.new(contexts || {})
466
473
  empty_ctx = Quonfig::Context.new({})
467
474
 
475
+ self.last_unwrapped_overrides = {}
468
476
  Array(keys).each { |key| record_one_eval(aggregator, resolver, store, key, ctx) }
469
477
  Array(keys_no_ctx).each { |key| record_one_eval(aggregator, resolver, store, key, empty_ctx) }
470
478
  end
@@ -482,13 +490,28 @@ module IntegrationTestHelpers
482
490
  end
483
491
  return if result.nil?
484
492
 
493
+ # Confidential / decryptWith values must never appear in plaintext on
494
+ # the wire. EvalResult#reportable_value, when populated, is the
495
+ # `*****<md5>`-redacted substitute the resolver computed pre-decryption.
496
+ # When we substitute, stash the runtime unwrapped value so the
497
+ # post-projection can still assert YAML's `value` / `value_type` against
498
+ # the resolved plaintext (the YAML treats `value` as the runtime view
499
+ # and `selected_value` as the wire view).
500
+ selected_for_telemetry = result.unwrapped_value
501
+ if result.reportable_value
502
+ selected_for_telemetry = result.reportable_value
503
+ (self.last_unwrapped_overrides ||= {})[key] = {
504
+ unwrapped: result.unwrapped_value,
505
+ value_type: result.value_type
506
+ }
507
+ end
485
508
  aggregator.record(
486
509
  config_id: (cfg[:id] || cfg['id']).to_s,
487
510
  config_key: key,
488
511
  config_type: (cfg[:type] || cfg['type']).to_s,
489
512
  conditional_value_index: result.rule_index,
490
513
  weighted_value_index: result.weighted_value_index,
491
- selected_value: result.unwrapped_value,
514
+ selected_value: selected_for_telemetry,
492
515
  reason: result.wire_reason
493
516
  )
494
517
  end
@@ -545,6 +568,7 @@ module IntegrationTestHelpers
545
568
 
546
569
  def self.evaluation_summary_post(event)
547
570
  summaries = event.dig('summaries', 'summaries') || []
571
+ overrides = last_unwrapped_overrides || {}
548
572
  rows = []
549
573
  summaries.each do |summary|
550
574
  type_label = TYPE_LABELS[summary['type'].to_s] || summary['type'].to_s.upcase
@@ -552,6 +576,15 @@ module IntegrationTestHelpers
552
576
  counters.each do |counter|
553
577
  selected = counter['selectedValue'] || {}
554
578
  unwrapped, value_type = unwrap_selected(selected)
579
+ # When the resolver redacted this key (confidential / decryptWith),
580
+ # selected_value carries the redacted form on the wire but YAML's
581
+ # `value` / `value_type` should still reflect the runtime resolved
582
+ # plaintext. Restore from the side channel populated in
583
+ # record_one_eval.
584
+ if (override = overrides[summary['key']])
585
+ unwrapped = override[:unwrapped]
586
+ value_type = override[:value_type] if override[:value_type]
587
+ end
555
588
  row = {
556
589
  'key' => summary['key'],
557
590
  'type' => type_label,
@@ -153,4 +153,18 @@ class TestTelemetry < Minitest::Test
153
153
  IntegrationTestHelpers.feed_aggregator(aggregator, :context_shape, {}, contexts: {})
154
154
  IntegrationTestHelpers.assert_aggregator_post(aggregator, :context_shape, nil, endpoint: "/api/v1/context-shapes")
155
155
  end
156
+
157
+ # confidential plain string is redacted in selectedValue
158
+ def test_confidential_plain_string_is_redacted_in_selectedvalue
159
+ aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
160
+ IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["confidential.new.string"]}, contexts: {})
161
+ IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "confidential.new.string", "type" => "CONFIG", "value" => "hello.world", "value_type" => "string", "count" => 1, "reason" => 1, "selected_value" => {"string" => "*****18aa7"}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}], endpoint: "/api/v1/telemetry")
162
+ end
163
+
164
+ # confidential encrypted string is redacted using ciphertext hash
165
+ def test_confidential_encrypted_string_is_redacted_using_ciphertext_hash
166
+ aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
167
+ IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["a.secret.config"]}, contexts: {})
168
+ IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "a.secret.config", "type" => "CONFIG", "value" => "hello.world", "value_type" => "string", "count" => 1, "reason" => 1, "selected_value" => {"string" => "*****936c9"}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}], endpoint: "/api/v1/telemetry")
169
+ end
156
170
  end
@@ -129,4 +129,47 @@ class TestClientTelemetry < Minitest::Test
129
129
  client.get('does.not.exist', 'fallback')
130
130
  assert_nil summaries_agg.drain_event
131
131
  end
132
+
133
+ # Regression: client used to hardcode weighted_value_index: nil, so split
134
+ # variants never reported as REASON_SPLIT in telemetry.
135
+ def test_weighted_value_records_split_reason_and_index
136
+ weighted_config = {
137
+ 'id' => 'cid-weighted',
138
+ 'key' => 'feature-flag.weighted',
139
+ 'type' => 'feature_flag',
140
+ 'valueType' => 'string',
141
+ 'sendToClientSdk' => false,
142
+ 'default' => {
143
+ 'rules' => [
144
+ {
145
+ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }],
146
+ 'value' => {
147
+ 'type' => 'weighted_values',
148
+ 'value' => {
149
+ 'hashByPropertyName' => 'user.key',
150
+ 'weightedValues' => [
151
+ { 'value' => { 'type' => 'string', 'value' => 'control' }, 'weight' => 1 },
152
+ { 'value' => { 'type' => 'string', 'value' => 'variant' }, 'weight' => 99 }
153
+ ]
154
+ }
155
+ }
156
+ }
157
+ ]
158
+ },
159
+ 'environment' => nil
160
+ }
161
+
162
+ store = Quonfig::ConfigStore.new
163
+ store.set('feature-flag.weighted', weighted_config)
164
+
165
+ client, _reporter, summaries_agg, _conn = make_client_with_telemetry(store)
166
+ client.get('feature-flag.weighted', Quonfig::NO_DEFAULT_PROVIDED, 'user' => { 'key' => 'u1' })
167
+
168
+ counter = summaries_agg.drain_event['summaries']['summaries'][0]['counters'][0]
169
+ assert_equal Quonfig::EvalResult::REASON_SPLIT, counter['reason'],
170
+ 'weighted variant evaluation must report REASON_SPLIT (3)'
171
+ refute_nil counter['weightedValueIndex'],
172
+ 'weighted variant evaluation must report a weightedValueIndex'
173
+ assert_kind_of Integer, counter['weightedValueIndex']
174
+ end
132
175
  end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+ require 'json'
5
+ require 'tmpdir'
6
+ require 'fileutils'
7
+
8
+ # qfg-pj0.5 — Dev-context injection. When enable_quonfig_user_context: true
9
+ # (or env var QUONFIG_DEV_CONTEXT=true), the SDK reads ~/.quonfig/tokens.json
10
+ # (written by `qfg login`) and merges {'quonfig-user' => {'email' => ...}}
11
+ # into globalContext. Customer-supplied keys win on collision.
12
+ #
13
+ # Mirror of sdk-node qfg-pj0.3 / sdk-go qfg-pj0.4.
14
+ class TestDevContext < Minitest::Test
15
+ def setup
16
+ super
17
+ @tmphome = Dir.mktmpdir('quonfig-dev-ctx-')
18
+ FileUtils.mkdir_p(File.join(@tmphome, '.quonfig'))
19
+ @old_home = ENV.fetch('HOME', nil)
20
+ ENV['HOME'] = @tmphome
21
+ ENV.delete('QUONFIG_DEV_CONTEXT')
22
+ end
23
+
24
+ def teardown
25
+ ENV['HOME'] = @old_home
26
+ ENV.delete('QUONFIG_DEV_CONTEXT')
27
+ FileUtils.remove_entry(@tmphome) if @tmphome && Dir.exist?(@tmphome)
28
+ super
29
+ end
30
+
31
+ def write_tokens(payload)
32
+ File.write(File.join(@tmphome, '.quonfig', 'tokens.json'), JSON.generate(payload))
33
+ end
34
+
35
+ def global_context_of(client)
36
+ client.instance_variable_get(:@global_context)
37
+ end
38
+
39
+ # 1. RED: injects quonfig-user.email when option enabled and file exists
40
+ def test_injects_quonfig_user_email_when_option_enabled
41
+ write_tokens(userEmail: 'bob@foo.com', accessToken: 'x', refreshToken: 'y', expiresAt: 0)
42
+
43
+ client = Quonfig::Client.new(
44
+ Quonfig::Options.new(enable_quonfig_user_context: true),
45
+ store: Quonfig::ConfigStore.new
46
+ )
47
+
48
+ assert_equal({ 'quonfig-user' => { 'email' => 'bob@foo.com' } }, global_context_of(client))
49
+ end
50
+
51
+ # 2. RED: no-op when option disabled and no env var
52
+ def test_no_op_when_option_disabled
53
+ write_tokens(userEmail: 'bob@foo.com')
54
+
55
+ client = Quonfig::Client.new(
56
+ Quonfig::Options.new(global_context: { user: { 'plan' => 'pro' } }),
57
+ store: Quonfig::ConfigStore.new
58
+ )
59
+
60
+ assert_equal({ user: { 'plan' => 'pro' } }, global_context_of(client))
61
+ end
62
+
63
+ # 3. RED: no-op when option enabled but file missing
64
+ def test_no_op_when_file_missing
65
+ # No tokens.json written.
66
+ client = Quonfig::Client.new(
67
+ Quonfig::Options.new(
68
+ enable_quonfig_user_context: true,
69
+ global_context: { user: { 'plan' => 'pro' } }
70
+ ),
71
+ store: Quonfig::ConfigStore.new
72
+ )
73
+
74
+ assert_equal({ user: { 'plan' => 'pro' } }, global_context_of(client))
75
+ end
76
+
77
+ # 4. RED: no-op when file unparseable; warning emitted; init succeeds
78
+ def test_no_op_when_file_unparseable
79
+ File.write(File.join(@tmphome, '.quonfig', 'tokens.json'), '{not valid json')
80
+
81
+ client = Quonfig::Client.new(
82
+ Quonfig::Options.new(enable_quonfig_user_context: true),
83
+ store: Quonfig::ConfigStore.new
84
+ )
85
+
86
+ assert_equal({}, global_context_of(client))
87
+ # The dev-context loader emits a warning to stderr that we want to verify.
88
+ assert_stderr(['quonfig'])
89
+ end
90
+
91
+ # 5. RED: customer-supplied quonfig-user keys win on collision
92
+ def test_customer_global_context_wins
93
+ write_tokens(userEmail: 'bob@foo.com')
94
+
95
+ client = Quonfig::Client.new(
96
+ Quonfig::Options.new(
97
+ enable_quonfig_user_context: true,
98
+ global_context: { 'quonfig-user' => { 'email' => 'override@x.com' } }
99
+ ),
100
+ store: Quonfig::ConfigStore.new
101
+ )
102
+
103
+ assert_equal({ 'quonfig-user' => { 'email' => 'override@x.com' } }, global_context_of(client))
104
+ end
105
+
106
+ # 6. RED: env var QUONFIG_DEV_CONTEXT=true enables when option absent
107
+ def test_env_var_enables_when_option_absent
108
+ write_tokens(userEmail: 'bob@foo.com')
109
+ ENV['QUONFIG_DEV_CONTEXT'] = 'true'
110
+
111
+ client = Quonfig::Client.new(
112
+ Quonfig::Options.new,
113
+ store: Quonfig::ConfigStore.new
114
+ )
115
+
116
+ assert_equal({ 'quonfig-user' => { 'email' => 'bob@foo.com' } }, global_context_of(client))
117
+ end
118
+
119
+ # 7. RED: integration — rule keyed on quonfig-user.email fires when injected
120
+ def test_attribute_reaches_eval_context
121
+ write_tokens(userEmail: 'bob@foo.com')
122
+
123
+ flag_config = {
124
+ 'id' => 'cfg-flag',
125
+ 'key' => 'my-flag',
126
+ 'type' => 'feature_flag',
127
+ 'valueType' => 'bool',
128
+ 'sendToClientSdk' => false,
129
+ 'default' => {
130
+ 'rules' => [
131
+ { 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }], 'value' => { 'type' => 'bool', 'value' => false } }
132
+ ]
133
+ },
134
+ 'environment' => {
135
+ 'id' => 'Production',
136
+ 'rules' => [
137
+ {
138
+ 'criteria' => [{
139
+ 'propertyName' => 'quonfig-user.email',
140
+ 'operator' => 'PROP_IS_ONE_OF',
141
+ 'valueToMatch' => { 'type' => 'string_list', 'value' => ['bob@foo.com'] }
142
+ }],
143
+ 'value' => { 'type' => 'bool', 'value' => true }
144
+ },
145
+ { 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }], 'value' => { 'type' => 'bool', 'value' => false } }
146
+ ]
147
+ }
148
+ }
149
+
150
+ store = Quonfig::ConfigStore.new
151
+ store.set('my-flag', flag_config)
152
+
153
+ client = Quonfig::Client.new(
154
+ Quonfig::Options.new(
155
+ enable_quonfig_user_context: true,
156
+ environment: 'Production'
157
+ ),
158
+ store: store
159
+ )
160
+
161
+ assert_equal true, client.get_bool('my-flag')
162
+ end
163
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quonfig
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.8
4
+ version: 0.0.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Dwyer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-26 00:00:00.000000000 Z
11
+ date: 2026-04-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -213,6 +213,7 @@ files:
213
213
  - lib/quonfig/config_store.rb
214
214
  - lib/quonfig/context.rb
215
215
  - lib/quonfig/datadir.rb
216
+ - lib/quonfig/dev_context.rb
216
217
  - lib/quonfig/duration.rb
217
218
  - lib/quonfig/encryption.rb
218
219
  - lib/quonfig/error.rb
@@ -255,6 +256,7 @@ files:
255
256
  - test/fixtures/datafile.json
256
257
  - test/integration/test_context_precedence.rb
257
258
  - test/integration/test_datadir_environment.rb
259
+ - test/integration/test_dev_overrides.rb
258
260
  - test/integration/test_enabled.rb
259
261
  - test/integration/test_enabled_with_contexts.rb
260
262
  - test/integration/test_get.rb
@@ -278,6 +280,7 @@ files:
278
280
  - test/test_context_shape.rb
279
281
  - test/test_context_shape_aggregator.rb
280
282
  - test/test_datadir.rb
283
+ - test/test_dev_context.rb
281
284
  - test/test_duration.rb
282
285
  - test/test_encryption.rb
283
286
  - test/test_evaluation_summaries_aggregator.rb