diff --git a/docs/paykit-interface.md b/docs/paykit-interface.md index a7d433c31..ed304fb2e 100644 --- a/docs/paykit-interface.md +++ b/docs/paykit-interface.md @@ -269,7 +269,6 @@ conformant. - Add `accepts?(protocol)` on `Gate`; keep `x402_accepted?` / `mpp_accepted?` as deprecated aliases for one release. - Add `payer` and ensure `scheme` on `Payment`. -- Add `PayKit.configure_from_env`. - Stablecoin set: add `USDG`, `CASH`. - Wire `external_id` through every flow (currently inconsistent). diff --git a/ruby/lib/pay_kit/config.rb b/ruby/lib/pay_kit/config.rb index 54531af96..063679767 100644 --- a/ruby/lib/pay_kit/config.rb +++ b/ruby/lib/pay_kit/config.rb @@ -264,6 +264,26 @@ def configure @config end + def configure_from_env(prefix = "PAY_KIT_", env: ENV) + configure do |c| + env.each do |name, value| + next unless name.start_with?(prefix) + + case name.delete_prefix(prefix) + when "NETWORK" then c.network = value + when "RPC_URL" then c.rpc_url = value unless value.empty? + when "ACCEPT" then c.accept = value.split(",").map(&:strip) - [""] + when "STABLECOINS" then c.stablecoins = value.split(",").map(&:strip) - [""] + when "PREFLIGHT" then c.preflight = env_bool(name, value) + when "MPP_REALM" then c.mpp.realm = value + when "MPP_CHALLENGE_BINDING_SECRET" then c.mpp.challenge_binding_secret = value + when "MPP_EXPIRES_IN" then c.mpp.expires_in = env_positive_int(name, value) + when "X402_FACILITATOR_URL" then c.x402.facilitator_url = value + end + end + end + end + def config @config ||= Config.new end @@ -279,5 +299,22 @@ def reset! @config = nil @pricing = nil end + + private + + def env_bool(name, value) + case value.strip.downcase + when "1", "true", "yes", "on" then true + when "0", "false", "no", "off" then false + else raise ConfigurationError, "#{name} must be one of 1/true/yes/on or 0/false/no/off, got #{value.inspect}" + end + end + + def env_positive_int(name, value) + int = Integer(value, 10, exception: false) + return int if int&.positive? + + raise ConfigurationError, "#{name} must be a positive integer, got #{value.inspect}" + end end end diff --git a/ruby/test/pay_kit/config_test.rb b/ruby/test/pay_kit/config_test.rb index 21ea530eb..778ccc3fe 100644 --- a/ruby/test/pay_kit/config_test.rb +++ b/ruby/test/pay_kit/config_test.rb @@ -291,6 +291,88 @@ def test_x402_unknown_scheme_raises end end + # --- configure_from_env ---------------------------------------------- + + def test_configure_from_env_reads_scalars_onto_frozen_config + config = PayKit.configure_from_env(env: { + "PAY_KIT_NETWORK" => "solana_devnet", + "PAY_KIT_RPC_URL" => "https://rpc.example.com", + "PAY_KIT_MPP_REALM" => "Shop", + "PAY_KIT_MPP_CHALLENGE_BINDING_SECRET" => "rotate-me", + "PAY_KIT_MPP_EXPIRES_IN" => "90", + "PAY_KIT_X402_FACILITATOR_URL" => "https://facilitator.example.com" + }) + assert_same PayKit.config, config + assert config.frozen? + assert_equal :solana_devnet, config.network + assert_equal "https://rpc.example.com", config.rpc_url + assert_equal "Shop", config.mpp.realm + assert_equal "rotate-me", config.mpp.challenge_binding_secret + assert_equal 90, config.mpp.expires_in + assert_equal "https://facilitator.example.com", config.x402.facilitator_url + end + + def test_configure_from_env_splits_comma_lists + PayKit.configure_from_env(env: {"PAY_KIT_ACCEPT" => "x402, mpp", "PAY_KIT_STABLECOINS" => "USDC,PYUSD"}) + assert_equal %i[x402 mpp], PayKit.config.accept + assert_equal %i[USDC PYUSD], PayKit.config.stablecoins + end + + def test_configure_from_env_with_empty_env_keeps_defaults + PayKit.configure_from_env(env: {}) + assert_equal :solana_localnet, PayKit.config.network + assert_equal %i[x402 mpp], PayKit.config.accept + assert_equal %i[USDC], PayKit.config.stablecoins + assert_equal 300, PayKit.config.mpp.expires_in + end + + def test_configure_from_env_custom_prefix_ignores_default_prefix + PayKit.configure_from_env("APP_", env: {"APP_NETWORK" => "solana_devnet", "PAY_KIT_NETWORK" => "solana_mainnet"}) + assert_equal :solana_devnet, PayKit.config.network + end + + def test_configure_from_env_rejects_bad_values + [ + {"PAY_KIT_NETWORK" => "bitcoin"}, + {"PAY_KIT_PREFLIGHT" => "maybe"}, + {"PAY_KIT_MPP_EXPIRES_IN" => "soon"}, + {"PAY_KIT_STABLECOINS" => " , "} + ].each do |env| + PayKit.reset! + assert_raises(PayKit::ConfigurationError, env.inspect) { PayKit.configure_from_env(env: env) } + end + end + + def test_configure_from_env_preflight_accepts_python_boolean_words + {"1" => true, "True" => true, "YES" => true, " on " => true, + "0" => false, "false" => false, "No" => false, " OFF\n" => false}.each do |value, expected| + PayKit.reset! + PayKit.configure_from_env(env: {"PAY_KIT_PREFLIGHT" => value}) + assert_equal expected, PayKit.config.preflight, value + end + end + + def test_configure_from_env_empty_rpc_url_leaves_rpc_url_unset + PayKit.configure_from_env(env: {"PAY_KIT_RPC_URL" => ""}) + assert_nil PayKit.config.rpc_url + assert PayKit.config.using_public_rpc_default? + end + + def test_configure_from_env_rejects_non_positive_expires_in + %w[0 -5].each do |value| + PayKit.reset! + assert_raises(PayKit::ConfigurationError, value) do + PayKit.configure_from_env(env: {"PAY_KIT_MPP_EXPIRES_IN" => value}) + end + end + end + + def test_configure_from_env_ignores_lowercase_names + PayKit.configure_from_env(env: {"pay_kit_network" => "solana_devnet", "Pay_Kit_Rpc_Url" => "https://rpc.example.com"}) + assert_equal :solana_localnet, PayKit.config.network + assert_nil PayKit.config.rpc_url + end + private # Replace `PayKit::Preflight.run` with a no-op spy for the duration of