mint_condition 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '05148574ba34f2883f18ef3f8b44d7c4c247ab66be37c6049704720ca558d2c7'
4
+ data.tar.gz: fd3aade8100ad6917c046b4f46350be88ecf6f326a26471e416bb6d28729bed6
5
+ SHA512:
6
+ metadata.gz: c1421a11eac109d58e5591fb20628583844c253c018d0ae9790bca263d2128d56b53070c2111d2d971ccc5b3938fb0916811d3953bca97147bd473eaa97cda00
7
+ data.tar.gz: 6dc031e508f678a4405e7bddaf167710cb33b8bfafccff80b7939f01b8ec36e973e02fce6273f3a271a88845aeacd1f13d05e3221a13ddebab171ac86c2068b8
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,14 @@
1
+ # Contributing
2
+
3
+ Thanks for your interest in contributing to MintCondition.
4
+
5
+ ## Guidelines
6
+
7
+ - Keep changes focused and well-scoped.
8
+ - Include tests for new or changed behavior.
9
+ - Use clear, concise commit messages.
10
+
11
+ ## Licensing
12
+
13
+ By submitting a pull request, you agree that your contributions will be licensed
14
+ under the MIT License.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Strovers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # MintCondition
2
+
3
+ MintCondition is a lightweight, framework-agnostic entitlements engine. It lets you define
4
+ capabilities, evaluate plan rules, and return structured decisions (booleans and limits)
5
+ without tying your app to any specific billing or authorization system.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```bash
12
+ gem "mint_condition"
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ ```bash
18
+ bundle install
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ Register capabilities:
24
+
25
+ ```ruby
26
+ MintCondition::Capabilities.register(
27
+ "messages.send",
28
+ "messages.max",
29
+ "reports.export"
30
+ )
31
+ ```
32
+
33
+ Define a ruleset (per plan or tier):
34
+
35
+ ```ruby
36
+ ruleset = MintCondition::Ruleset.build do
37
+ allow "messages.send"
38
+ limit "messages.max", 1000
39
+ deny "reports.export"
40
+ end
41
+ ```
42
+
43
+ Evaluate entitlements:
44
+
45
+ ```ruby
46
+ checker = MintCondition::Checker.new(ruleset: ruleset, context: { plan: "pro" })
47
+
48
+ checker.allowed?("messages.send")
49
+ # => true
50
+
51
+ decision = checker.allow("messages.max")
52
+ decision.allowed?
53
+ # => true
54
+ decision.value
55
+ # => 1000
56
+ ```
57
+
58
+ You can also use the module-level helpers:
59
+
60
+ ```ruby
61
+ MintCondition.allowed?("messages.send", ruleset: ruleset)
62
+ ```
63
+
64
+ Configure unknown capability behavior and instrumentation:
65
+
66
+ ```ruby
67
+ MintCondition.configure do |config|
68
+ config.unknown_capability = :allow # :deny or :raise
69
+ config.instrumenter = lambda do |event, payload|
70
+ # hook into your logger or tracer
71
+ end
72
+ end
73
+ ```
74
+
75
+ Use `MintCondition::Limits::UNLIMITED` to represent no limits in a ruleset.
76
+
77
+ ## Development
78
+
79
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
80
+
81
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
82
+
83
+ ## Contributing
84
+
85
+ Bug reports and pull requests are welcome. See `CONTRIBUTING.md` for details.
86
+
87
+ ## Security
88
+
89
+ Please report vulnerabilities privately. See `SECURITY.md` for details.
90
+
91
+ ## License
92
+
93
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/SECURITY.md ADDED
@@ -0,0 +1,15 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a Vulnerability
4
+
5
+ If you discover a security vulnerability, please report it privately by emailing
6
+ adstrovers@gmail.com.
7
+
8
+ Please include:
9
+
10
+ - A clear description of the issue
11
+ - Steps to reproduce
12
+ - Any potential impact you have identified
13
+
14
+ We will acknowledge receipt within 72 hours and provide a timeline for a fix
15
+ when possible.
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module MintCondition
6
+ module Capabilities
7
+ @registry = Set.new
8
+
9
+ def self.register(*names)
10
+ names.flatten.each { |name| @registry << normalize(name) }
11
+ end
12
+
13
+ def self.known?(name)
14
+ @registry.include?(normalize(name))
15
+ end
16
+
17
+ def self.clear!
18
+ @registry.clear
19
+ end
20
+
21
+ def self.all
22
+ @registry.to_a.sort
23
+ end
24
+
25
+ def self.normalize(name)
26
+ name.to_s
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MintCondition
4
+ class Checker
5
+ attr_reader :context, :ruleset, :capabilities, :config
6
+
7
+ def initialize(ruleset:, context: nil, capabilities: Capabilities, config: MintCondition.config)
8
+ @ruleset = ruleset.is_a?(Ruleset) ? ruleset : Ruleset.new(ruleset || {})
9
+ @context = context
10
+ @capabilities = capabilities
11
+ @config = config
12
+ end
13
+
14
+ def allow(capability, **meta)
15
+ normalized = capabilities.normalize(capability)
16
+
17
+ unless capabilities.known?(normalized)
18
+ decision = unknown_decision(normalized, meta)
19
+ instrument("mint_condition.unknown_capability", decision, normalized, meta)
20
+ raise UnknownCapabilityError, "Unknown capability: #{normalized}" if config.unknown_capability == :raise
21
+
22
+ return decision
23
+ end
24
+
25
+ unless ruleset.include?(normalized)
26
+ decision = Decision.new(
27
+ allowed: false,
28
+ reason: :not_entitled,
29
+ meta: decision_meta(normalized, meta)
30
+ )
31
+ instrument("mint_condition.denied", decision, normalized, meta)
32
+ return decision
33
+ end
34
+
35
+ value = ruleset.value_for(normalized)
36
+ decision = decision_for(value, normalized, meta)
37
+ instrument("mint_condition.denied", decision, normalized, meta) if decision.denied?
38
+ decision
39
+ end
40
+
41
+ def allowed?(capability, **meta)
42
+ allow(capability, **meta).allowed?
43
+ end
44
+
45
+ private
46
+
47
+ def decision_for(value, capability, meta)
48
+ case value
49
+ when true
50
+ Decision.new(allowed: true, reason: :allowed, meta: decision_meta(capability, meta))
51
+ when false
52
+ Decision.new(allowed: false, reason: :not_entitled, meta: decision_meta(capability, meta))
53
+ else
54
+ Decision.new(
55
+ allowed: true,
56
+ reason: :limit,
57
+ value: value,
58
+ meta: decision_meta(capability, meta)
59
+ )
60
+ end
61
+ end
62
+
63
+ def unknown_decision(capability, meta)
64
+ case config.unknown_capability
65
+ when :deny
66
+ Decision.new(allowed: false, reason: :unknown_capability, meta: decision_meta(capability, meta))
67
+ else
68
+ Decision.new(allowed: true, reason: :unknown_capability, meta: decision_meta(capability, meta))
69
+ end
70
+ end
71
+
72
+ def decision_meta(capability, meta)
73
+ meta.merge(capability: capability)
74
+ end
75
+
76
+ def instrument(event, decision, capability, meta)
77
+ MintCondition.instrument(
78
+ event,
79
+ decision_meta(capability, meta).merge(
80
+ allowed: decision.allowed?,
81
+ reason: decision.reason,
82
+ value: decision.value,
83
+ context: context
84
+ )
85
+ )
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MintCondition
4
+ class Configuration
5
+ attr_accessor :unknown_capability, :instrumenter
6
+
7
+ def initialize
8
+ @unknown_capability = :allow
9
+ @instrumenter = ->(_event, _payload) {}
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MintCondition
4
+ Decision = Struct.new(
5
+ :allowed,
6
+ :reason,
7
+ :value,
8
+ :meta,
9
+ keyword_init: true
10
+ ) do
11
+ def allowed?
12
+ !!allowed
13
+ end
14
+
15
+ def denied?
16
+ !allowed?
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MintCondition
4
+ class Error < StandardError; end
5
+
6
+ class UnknownCapabilityError < Error; end
7
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MintCondition
4
+ module Limits
5
+ UNLIMITED = :unlimited
6
+
7
+ def self.unlimited?(value)
8
+ value == UNLIMITED
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MintCondition
4
+ class Ruleset
5
+ def self.build(&block)
6
+ ruleset = new
7
+ ruleset.instance_eval(&block) if block
8
+ ruleset
9
+ end
10
+
11
+ def initialize(rules = {})
12
+ @rules = {}
13
+ rules.each { |capability, value| set(capability, value) }
14
+ end
15
+
16
+ def allow(capability)
17
+ set(capability, true)
18
+ end
19
+
20
+ def deny(capability)
21
+ set(capability, false)
22
+ end
23
+
24
+ def limit(capability, value)
25
+ set(capability, value)
26
+ end
27
+
28
+ def set(capability, value)
29
+ @rules[Capabilities.normalize(capability)] = value
30
+ end
31
+
32
+ def include?(capability)
33
+ @rules.key?(Capabilities.normalize(capability))
34
+ end
35
+
36
+ def value_for(capability)
37
+ @rules[Capabilities.normalize(capability)]
38
+ end
39
+
40
+ def to_h
41
+ @rules.dup
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MintCondition
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mint_condition/version"
4
+ require_relative "mint_condition/errors"
5
+ require_relative "mint_condition/configuration"
6
+ require_relative "mint_condition/decision"
7
+ require_relative "mint_condition/limits"
8
+ require_relative "mint_condition/capabilities"
9
+ require_relative "mint_condition/ruleset"
10
+ require_relative "mint_condition/checker"
11
+
12
+ module MintCondition
13
+ class << self
14
+ def config
15
+ @config ||= Configuration.new
16
+ end
17
+
18
+ def configure
19
+ yield(config)
20
+ end
21
+
22
+ def for(ruleset:, context: nil, **options)
23
+ Checker.new(ruleset: ruleset, context: context, **options)
24
+ end
25
+
26
+ def allow(capability, ruleset:, context: nil, **options)
27
+ self.for(ruleset: ruleset, context: context, **options).allow(capability)
28
+ end
29
+
30
+ def allowed?(capability, ruleset:, context: nil, **options)
31
+ self.for(ruleset: ruleset, context: context, **options).allowed?(capability)
32
+ end
33
+
34
+ def reset_config!
35
+ @config = Configuration.new
36
+ end
37
+
38
+ def instrument(event, payload = {})
39
+ instrumenter = config.instrumenter
40
+ return unless instrumenter.respond_to?(:call)
41
+
42
+ instrumenter.call(event, payload)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,4 @@
1
+ module MintCondition
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mint_condition
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andrew Strovers
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-01-10 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: MintCondition provides a minimal, observable entitlements engine with
14
+ capabilities, rulesets, and structured decisions.
15
+ email:
16
+ - adstrovers@gmail.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - ".rspec"
22
+ - CONTRIBUTING.md
23
+ - LICENSE
24
+ - README.md
25
+ - Rakefile
26
+ - SECURITY.md
27
+ - lib/mint_condition.rb
28
+ - lib/mint_condition/capabilities.rb
29
+ - lib/mint_condition/checker.rb
30
+ - lib/mint_condition/configuration.rb
31
+ - lib/mint_condition/decision.rb
32
+ - lib/mint_condition/errors.rb
33
+ - lib/mint_condition/limits.rb
34
+ - lib/mint_condition/ruleset.rb
35
+ - lib/mint_condition/version.rb
36
+ - sig/mint_condition.rbs
37
+ homepage: https://github.com/ADStrovers/mint_condition
38
+ licenses:
39
+ - MIT
40
+ metadata:
41
+ allowed_push_host: https://rubygems.org
42
+ homepage_uri: https://github.com/ADStrovers/mint_condition
43
+ source_code_uri: https://github.com/ADStrovers/mint_condition
44
+ changelog_uri: https://github.com/ADStrovers/mint_condition/releases
45
+ post_install_message:
46
+ rdoc_options: []
47
+ require_paths:
48
+ - lib
49
+ required_ruby_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 3.1.0
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ requirements: []
60
+ rubygems_version: 3.5.22
61
+ signing_key:
62
+ specification_version: 4
63
+ summary: Framework-agnostic entitlements and capability evaluation.
64
+ test_files: []