legionio 1.7.19 → 1.7.21

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: c2114c2b32ae4dbc1650bef546391f6abb1499a0b9d96d577739503c47caba79
4
- data.tar.gz: 1e95267b23de0b635ffb32c9f745f58fe0bac63aa7d806a6581e3557b9b1aad6
3
+ metadata.gz: f1c44047319bdcf02eb061d73517101f695f380d976cb65ad9e2a0d2afb824b2
4
+ data.tar.gz: e756182d3fdfea35882961412f7d3093884d7477954c3b67d2037039ad310189
5
5
  SHA512:
6
- metadata.gz: 56c140c38d73a16c97574b405c4f27bdb91de199758692e76aecd0ec55afad66acab08fc011cf31a658df408cfcf8506e5e2971f4fbe92a4276e8856153a7481
7
- data.tar.gz: 903faf2b78e2f27e1176e03558138f58ee9e6bdfcc34ac3f61c8c933424a0f7a0c641ba97d38d27c527b41cad93c9d8f69bffb64fed8a032a5d1aeb2bd6cb5ff
6
+ metadata.gz: 1178b2efaadc7801e37a68fd556add983eede7acd3f01dd1c744b029c8e048dc7ac681db0b578fc545419ca8d4f447cc518d3f7d5c2b351323350c1ae9ea908c
7
+ data.tar.gz: c9f34008bef206a7108cbc4013213342c4bc25fa6396884fec1e471b3b6d46d7110027214a0829c05a0d59d59c348fad8726d089c03ffbb1b40ce5f7d9de10a7
data/.rubocop.yml CHANGED
@@ -3,6 +3,9 @@ AllCops:
3
3
  NewCops: enable
4
4
  SuggestExtensions: false
5
5
 
6
+ Style/RedundantConstantBase:
7
+ Enabled: false
8
+
6
9
  Layout/LineLength:
7
10
  Max: 160
8
11
  Exclude:
data/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.7.21] - 2026-04-06
6
+ ### Fixed
7
+ - Optional components (rbac, llm, apollo, gaia) no longer block readiness when not installed
8
+ - Split `Readiness::COMPONENTS` into `REQUIRED_COMPONENTS` and `OPTIONAL_COMPONENTS`
9
+ - Added `Readiness.mark_skipped` for components that are absent or disabled
10
+ - Reload path now correctly marks optional components as skipped when not loaded
11
+
12
+ ## [1.7.20] - 2026-04-06
13
+ ### Added
14
+ - `Legion::Mode` module with `LEGACY_MAP`, ENV/Settings fallback chain, `agent?`/`worker?`/`infra?`/`lite?` predicates
15
+ - `Legion.instance_id` — UUID computed at load time, ENV override via `LEGIONIO_INSTANCE_ID`
16
+ - `Legion::Identity::Process` singleton — process identity with `bind!`, `bind_fallback!`, `queue_prefix` per-mode, `AtomicReference` thread safety
17
+ - `Legion::Identity::Request` — per-request immutable identity with `from_env`, `from_auth_context`, `to_caller_hash`, `to_rbac_principal`
18
+ - `Legion::Identity::Lease` — credential lease value object with `expired?`, `stale?` (50% TTL), `ttl_seconds`, `valid?`
19
+ - `Legion::Identity::LeaseRenewer` — background thread per provider, 50% TTL renewal, cooperative shutdown (no `Thread#kill`)
20
+ - `Legion::Identity::Broker` — provider management with groups cache (60s TTL, single-flight CAS), `token_for`, `credentials_for`, `shutdown`
21
+ - `Legion::Identity::Middleware` — Rack middleware bridging `legion.auth` to `legion.principal` (`Identity::Request`)
22
+ - `setup_identity` boot step 9 — parallel provider resolution via `Concurrent::Promises`, fallback to `ENV['USER']`
23
+ - Extension publish suppression — defers `LexRegister.publish` until identity resolves, `flush_pending_registrations!`
24
+ - Identity provider auto-registration during phased extension load (`identity_provider?` duck-type check)
25
+ - `GET /api/identity/audit` route with principal and duration filtering
26
+ - `legion doctor` checks: `ApiBindCheck` (non-loopback without auth), `ModeCheck` (no explicit process.mode)
27
+
28
+ ### Changed
29
+ - `Readiness.status` upgraded to `Concurrent::Hash` for thread safety; `:identity` added to `COMPONENTS`
30
+ - `READONLY_SECTIONS` extended with `:identity`, `:rbac`, `:api`
31
+ - Default API bind changed from `0.0.0.0` to `127.0.0.1`
32
+ - `ProcessRole` delegates `.current` to `Mode.current`; added `:agent` and `:infra` role entries
33
+ - `lite_mode?` delegates to `Mode.lite?`
34
+ - Reload path adds `Identity::Process.refresh_credentials` after transport reconnect
35
+ - Shutdown adds cooperative `Identity::Broker.shutdown` and JWKS background refresh stop
36
+
5
37
  ## [1.7.19] - 2026-04-06
6
38
 
7
39
  ### Added
data/CLAUDE.md CHANGED
@@ -9,7 +9,7 @@ The primary gem for the LegionIO framework. An extensible async job engine for s
9
9
 
10
10
  **GitHub**: https://github.com/LegionIO/LegionIO
11
11
  **Gem**: `legionio`
12
- **Version**: 1.7.18
12
+ **Version**: 1.7.21
13
13
  **License**: Apache-2.0
14
14
  **Docker**: `legionio/legion`
15
15
  **Ruby**: >= 3.4
data/README.md CHANGED
@@ -8,13 +8,13 @@ Schedule tasks, chain services into dependency graphs, run them concurrently via
8
8
  ╭──────────────────────────────────────╮
9
9
  │ L E G I O N I O │
10
10
  │ │
11
- │ 280+ extensions · 58 MCP tools │
11
+ │ 280+ extensions · 60 MCP tools │
12
12
  │ AI chat CLI · REST API · HA │
13
13
  │ cognitive architecture · Vault │
14
14
  ╰──────────────────────────────────────╯
15
15
  ```
16
16
 
17
- **Ruby >= 3.4** | **v1.6.20** | **Apache-2.0** | [@Esity](https://github.com/Esity)
17
+ **Ruby >= 3.4** | **v1.7.21** | **Apache-2.0** | [@Esity](https://github.com/Esity)
18
18
 
19
19
  ---
20
20
 
@@ -33,7 +33,7 @@ When A completes, B runs. B triggers C, D, and E in parallel. Conditions gate ex
33
33
  But that's just the foundation. LegionIO is also:
34
34
 
35
35
  - **An AI coding assistant** — interactive chat with tools, code review, commit messages, PR generation, and multi-agent workflows
36
- - **An MCP server** — 58 tools that let any AI agent run tasks, manage extensions, and query your infrastructure
36
+ - **An MCP server** — 60 tools that let any AI agent run tasks, manage extensions, and query your infrastructure
37
37
  - **A cognitive computing platform** — 242 brain-modeled extensions across 18 cognitive domains
38
38
  - **A digital worker platform** — AI-as-labor with governance, risk tiers, and cost tracking
39
39
 
@@ -359,7 +359,7 @@ legion mcp http # streamable HTTP on localhost:9393
359
359
  legion mcp http --port 8080 --host 0.0.0.0
360
360
  ```
361
361
 
362
- **58 tools** in the `legion.*` namespace:
362
+ **60 tools** in the `legion.*` namespace:
363
363
 
364
364
  | Category | Tools |
365
365
  |----------|-------|
@@ -9,7 +9,7 @@ module Legion
9
9
  {
10
10
  enabled: true,
11
11
  port: 4567,
12
- bind: '0.0.0.0',
12
+ bind: '127.0.0.1',
13
13
  puma: puma_defaults,
14
14
  bind_retries: 3,
15
15
  bind_retry_wait: 2,
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module Routes
6
+ module IdentityAudit
7
+ def self.registered(app)
8
+ app.helpers IdentityAuditHelpers
9
+
10
+ app.get '/api/identity/audit' do
11
+ halt 503, json_error('unavailable', 'audit records not available') unless defined?(Legion::Data::Model::AuditRecord)
12
+
13
+ dataset = Legion::Data::Model::AuditRecord.where(entity_type: 'identity')
14
+
15
+ principal = params[:principal]
16
+ dataset = dataset.where(Sequel.lit("metadata->>'principal' = ?", principal)) if principal
17
+
18
+ since = params[:since]
19
+ if since
20
+ duration = parse_since_duration(since)
21
+ dataset = dataset.where { created_at >= Time.now - duration } if duration
22
+ end
23
+
24
+ records = dataset.order(Sequel.desc(:created_at)).limit(100).all
25
+ json_collection(records.map do |r|
26
+ { id: r.id, action: r.action, entity_type: r.entity_type, metadata: r.parsed_metadata, created_at: r.created_at }
27
+ end)
28
+ end
29
+ end
30
+
31
+ module IdentityAuditHelpers
32
+ def parse_since_duration(value)
33
+ return nil unless value.is_a?(String)
34
+
35
+ case value
36
+ when /\A(\d+)h\z/ then Regexp.last_match(1).to_i * 3600
37
+ when /\A(\d+)m\z/ then Regexp.last_match(1).to_i * 60
38
+ when /\A(\d+)s\z/ then Regexp.last_match(1).to_i
39
+ when /\A(\d+)d\z/ then Regexp.last_match(1).to_i * 86_400
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -5,7 +5,7 @@ module Legion
5
5
  module Routes
6
6
  module Settings
7
7
  SENSITIVE_KEYS = %i[password secret token key cert private_key api_key].freeze
8
- READONLY_SECTIONS = %i[crypt transport].freeze
8
+ READONLY_SECTIONS = %i[crypt transport identity rbac api].freeze
9
9
 
10
10
  def self.registered(app)
11
11
  app.get '/api/settings' do
data/lib/legion/api.rb CHANGED
@@ -60,6 +60,7 @@ require_relative 'api/tbi_patterns'
60
60
  require_relative 'api/webhooks'
61
61
  require_relative 'api/tenants'
62
62
  require_relative 'api/inbound_webhooks'
63
+ require_relative 'api/identity_audit'
63
64
  require_relative 'api/graphql' if defined?(GraphQL)
64
65
 
65
66
  module Legion
@@ -216,6 +217,7 @@ module Legion
216
217
  register Routes::Webhooks
217
218
  register Routes::Tenants
218
219
  register Routes::InboundWebhooks
220
+ register Routes::IdentityAudit
219
221
  register Routes::GraphQL if defined?(Routes::GraphQL)
220
222
 
221
223
  use Legion::API::Middleware::RequestLogger
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module CLI
5
+ class Doctor
6
+ class ApiBindCheck
7
+ LOOPBACK_BINDS = %w[127.0.0.1 ::1 localhost].freeze
8
+
9
+ def name
10
+ 'API bind address'
11
+ end
12
+
13
+ def run
14
+ return skip_result unless defined?(Legion::Settings)
15
+
16
+ api_settings = Legion::Settings[:api]
17
+ return skip_result unless api_settings.is_a?(Hash)
18
+
19
+ bind = api_settings[:bind]
20
+ return skip_result if bind.nil?
21
+
22
+ if LOOPBACK_BINDS.include?(bind)
23
+ Result.new(
24
+ name: name,
25
+ status: :pass,
26
+ message: "API bound to loopback (#{bind})"
27
+ )
28
+ elsif api_settings.dig(:auth, :enabled) == true
29
+ Result.new(
30
+ name: name,
31
+ status: :pass,
32
+ message: "API bound to #{bind} with auth enabled"
33
+ )
34
+ else
35
+ Result.new(
36
+ name: name,
37
+ status: :warn,
38
+ message: "API bound to non-loopback address (#{bind}) without explicit auth configuration",
39
+ prescription: "Set api.auth.enabled: true or change api.bind to '127.0.0.1'"
40
+ )
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def skip_result
47
+ Result.new(
48
+ name: name,
49
+ status: :pass,
50
+ message: 'API settings not loaded'
51
+ )
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module CLI
5
+ class Doctor
6
+ class ModeCheck
7
+ def name
8
+ 'Process mode'
9
+ end
10
+
11
+ def run
12
+ unless defined?(Legion::Settings)
13
+ return Result.new(
14
+ name: name,
15
+ status: :pass,
16
+ message: 'Settings not loaded'
17
+ )
18
+ end
19
+
20
+ explicit_mode = Legion::Settings.dig(:process, :mode) || Legion::Settings[:mode]
21
+
22
+ if explicit_mode
23
+ Result.new(
24
+ name: name,
25
+ status: :pass,
26
+ message: "Explicit process mode configured: #{explicit_mode}"
27
+ )
28
+ else
29
+ Result.new(
30
+ name: name,
31
+ status: :warn,
32
+ message: 'No explicit process.mode configured (defaulting to agent)',
33
+ prescription: 'Set {"process": {"mode": "agent"}} in settings to prepare for Phase 9 default change to worker'
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -17,6 +17,8 @@ module Legion
17
17
  autoload :PidCheck, 'legion/cli/doctor/pid_check'
18
18
  autoload :PermissionsCheck, 'legion/cli/doctor/permissions_check'
19
19
  autoload :TlsCheck, 'legion/cli/doctor/tls_check'
20
+ autoload :ApiBindCheck, 'legion/cli/doctor/api_bind_check'
21
+ autoload :ModeCheck, 'legion/cli/doctor/mode_check'
20
22
 
21
23
  def self.exit_on_failure?
22
24
  true
@@ -37,6 +39,8 @@ module Legion
37
39
  PidCheck
38
40
  PermissionsCheck
39
41
  TlsCheck
42
+ ApiBindCheck
43
+ ModeCheck
40
44
  ].freeze
41
45
 
42
46
  # Weights: security > connectivity > convenience
@@ -22,6 +22,7 @@ module Legion
22
22
  @actors = []
23
23
  @running_instances = Concurrent::Array.new
24
24
  @loaded_extensions = []
25
+ @pending_registrations = Concurrent::Array.new
25
26
 
26
27
  find_extensions
27
28
 
@@ -106,6 +107,21 @@ module Legion
106
107
  Legion::Logging.info "Successfully shut down all actors (#{(Time.now - shutdown_start).round(1)}s)"
107
108
  end
108
109
 
110
+ def flush_pending_registrations!
111
+ return if @pending_registrations.nil? || @pending_registrations.empty?
112
+
113
+ registrations = @pending_registrations
114
+ count = registrations.size
115
+ @pending_registrations = nil
116
+
117
+ registrations.each do |registration|
118
+ registration.publish
119
+ rescue StandardError => e
120
+ Legion::Logging.warn "[Extensions] flush registration failed: #{e.message}" if defined?(Legion::Logging)
121
+ end
122
+ Legion::Logging.info "[Extensions] flushed #{count} pending registrations" if defined?(Legion::Logging)
123
+ end
124
+
109
125
  def pause_actors
110
126
  @running_instances&.each do |inst|
111
127
  timer = inst.instance_variable_get(:@timer)
@@ -253,8 +269,15 @@ module Legion
253
269
  has_logger = extension.respond_to?(:log)
254
270
  extension.autobuild
255
271
 
272
+ register_identity_provider(extension, entry) if identity_provider?(extension)
273
+
256
274
  require 'legion/transport/messages/lex_register'
257
- Legion::Transport::Messages::LexRegister.new(function: 'save', opts: extension.runners).publish
275
+ registration = Legion::Transport::Messages::LexRegister.new(function: 'save', opts: extension.runners)
276
+ if @pending_registrations
277
+ @pending_registrations << registration
278
+ else
279
+ registration.publish
280
+ end
258
281
 
259
282
  register_capabilities(entry[:gem_name], extension.runners) if extension.respond_to?(:runners)
260
283
  write_lex_cli_manifest(entry, extension)
@@ -405,6 +428,39 @@ module Legion
405
428
 
406
429
  private
407
430
 
431
+ def identity_provider?(extension)
432
+ extension.respond_to?(:provider_name) &&
433
+ extension.respond_to?(:provider_type) &&
434
+ extension.respond_to?(:facing)
435
+ end
436
+
437
+ def register_identity_provider(extension, entry)
438
+ return unless defined?(Legion::Data) && Legion::Data.connected?
439
+ return unless defined?(Legion::Data::Model::IdentityProvider)
440
+
441
+ name = extension.provider_name.to_s
442
+ attrs = {
443
+ provider_type: extension.provider_type.to_s,
444
+ facing: extension.facing.to_s,
445
+ priority: extension.respond_to?(:priority) ? extension.priority : 100,
446
+ trust_weight: extension.respond_to?(:trust_weight) ? extension.trust_weight : 50,
447
+ capabilities: extension.respond_to?(:capabilities) ? Array(extension.capabilities).map(&:to_s) : [],
448
+ source: 'gem',
449
+ enabled: true
450
+ }
451
+
452
+ existing = Legion::Data::Model::IdentityProvider.where(name: name).first
453
+ if existing
454
+ diverged = attrs.any? { |k, v| existing.send(k).to_s != v.to_s }
455
+ Legion::Logging.info "[identity][provider] name=#{name} source=db/gem diverged=#{diverged}" if defined?(Legion::Logging)
456
+ else
457
+ Legion::Data::Model::IdentityProvider.insert_conflict(target: :name, update: attrs).insert(attrs.merge(name: name))
458
+ Legion::Logging.info "[identity][provider] name=#{name} registered" if defined?(Legion::Logging)
459
+ end
460
+ rescue StandardError => e
461
+ Legion::Logging.warn "[identity][provider] registration failed for #{entry[:gem_name]}: #{e.message}" if defined?(Legion::Logging)
462
+ end
463
+
408
464
  def write_lex_cli_manifest(entry, extension)
409
465
  require 'legion/cli/lex_cli_manifest'
410
466
 
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+
5
+ module Legion
6
+ module Identity
7
+ module Broker
8
+ GROUPS_CACHE_TTL = 60
9
+
10
+ class << self
11
+ def token_for(provider_name)
12
+ renewer = renewers[provider_name.to_sym]
13
+ return nil unless renewer
14
+
15
+ lease = renewer.current_lease
16
+ lease&.valid? ? lease.token : nil
17
+ end
18
+
19
+ def credentials_for(provider_name, service: nil)
20
+ renewer = renewers[provider_name.to_sym]
21
+ return nil unless renewer
22
+
23
+ lease = renewer.current_lease
24
+ return nil unless lease&.valid?
25
+
26
+ { token: lease.token, provider: provider_name.to_sym, service: service, lease: lease }
27
+ end
28
+
29
+ def register_provider(provider_name, provider:, lease:)
30
+ name = provider_name.to_sym
31
+ renewers[name]&.stop!
32
+ renewers[name] = LeaseRenewer.new(
33
+ provider_name: name,
34
+ provider: provider,
35
+ lease: lease
36
+ )
37
+ end
38
+
39
+ def authenticated?
40
+ Identity::Process.resolved?
41
+ end
42
+
43
+ def groups
44
+ cached = @groups_cache&.get
45
+ return cached[:groups] if cached && (Time.now - cached[:fetched_at]) < GROUPS_CACHE_TTL
46
+
47
+ if @groups_fetch_in_progress.make_true
48
+ begin
49
+ fetched = fetch_groups
50
+ @groups_cache.set({ groups: fetched, fetched_at: Time.now })
51
+ fetched
52
+ ensure
53
+ @groups_fetch_in_progress.make_false
54
+ end
55
+ else
56
+ loop do
57
+ current = @groups_cache&.get
58
+ return current[:groups] if current
59
+
60
+ break unless @groups_fetch_in_progress.true?
61
+
62
+ sleep(0.01)
63
+ end
64
+
65
+ cached ? cached[:groups] : []
66
+ end
67
+ end
68
+
69
+ def invalidate_groups_cache!
70
+ @groups_cache.set(nil)
71
+ end
72
+
73
+ def emails
74
+ process_state = Identity::Process.identity_hash
75
+ metadata = process_state[:metadata] || {}
76
+ Array(metadata[:emails])
77
+ end
78
+
79
+ def providers
80
+ renewers.keys
81
+ end
82
+
83
+ def leases
84
+ renewers.transform_values { |r| r.current_lease&.to_h }
85
+ end
86
+
87
+ def shutdown
88
+ renewers.each_value do |r|
89
+ r.stop!
90
+ rescue Exception # rubocop:disable Lint/RescueException
91
+ nil
92
+ end
93
+ renewers.clear
94
+ end
95
+
96
+ def reset!
97
+ shutdown
98
+ @groups_cache = Concurrent::AtomicReference.new(nil)
99
+ @groups_fetch_in_progress = Concurrent::AtomicBoolean.new(false)
100
+ end
101
+
102
+ private
103
+
104
+ def renewers
105
+ @renewers ||= Concurrent::Hash.new
106
+ end
107
+
108
+ def fetch_groups
109
+ process_groups = Identity::Process.identity_hash[:groups]
110
+ return process_groups if process_groups && !process_groups.empty?
111
+
112
+ return db_groups if db_available?
113
+
114
+ []
115
+ end
116
+
117
+ def db_groups
118
+ return [] unless defined?(Legion::Data) && Legion::Data.respond_to?(:connected?) && Legion::Data.connected?
119
+
120
+ model = begin
121
+ Legion::Data::Model::IdentityGroupMembership
122
+ rescue StandardError
123
+ nil
124
+ end
125
+ return [] unless model
126
+
127
+ principal_id = Identity::Process.id
128
+ memberships = model.where(principal_id: principal_id, status: 'active').all
129
+ memberships.filter_map do |m|
130
+ m.group.name
131
+ rescue StandardError
132
+ nil
133
+ end
134
+ rescue StandardError => e
135
+ log_warn("Broker.db_groups failed: #{e.message}")
136
+ []
137
+ end
138
+
139
+ def db_available?
140
+ defined?(Legion::Data) &&
141
+ Legion::Data.respond_to?(:connected?) &&
142
+ Legion::Data.connected?
143
+ end
144
+
145
+ def log_warn(message)
146
+ if defined?(Legion::Logging) && Legion::Logging.respond_to?(:warn)
147
+ Legion::Logging.warn("[Identity::Broker] #{message}")
148
+ else
149
+ $stderr.puts "[Identity::Broker] #{message}" # rubocop:disable Style/StderrPuts
150
+ end
151
+ end
152
+ end
153
+
154
+ # Initialize atomics at module definition time
155
+ @groups_cache = Concurrent::AtomicReference.new(nil)
156
+ @groups_fetch_in_progress = Concurrent::AtomicBoolean.new(false)
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Identity
5
+ class Lease
6
+ attr_reader :provider, :credential, :lease_id, :expires_at, :renewable, :issued_at, :metadata
7
+
8
+ def initialize(provider:, credential:, lease_id: nil, expires_at: nil, renewable: false, issued_at: nil, metadata: {}) # rubocop:disable Metrics/ParameterLists
9
+ @provider = provider
10
+ @credential = credential
11
+ @lease_id = lease_id
12
+ @expires_at = expires_at
13
+ @renewable = renewable
14
+ @issued_at = issued_at || Time.now
15
+ @metadata = metadata.freeze
16
+ end
17
+
18
+ def token
19
+ credential
20
+ end
21
+
22
+ def expired?
23
+ return false if expires_at.nil?
24
+
25
+ Time.now >= expires_at
26
+ end
27
+
28
+ def stale?
29
+ return false if expires_at.nil? || issued_at.nil?
30
+
31
+ elapsed = Time.now - issued_at
32
+ total = expires_at - issued_at
33
+ return false if total <= 0
34
+
35
+ elapsed >= (total * 0.5)
36
+ end
37
+
38
+ def ttl_seconds
39
+ return nil if expires_at.nil?
40
+
41
+ remaining = expires_at - Time.now
42
+ remaining.negative? ? 0 : remaining.to_i
43
+ end
44
+
45
+ def valid?
46
+ !credential.nil? && !expired?
47
+ end
48
+
49
+ def to_h
50
+ {
51
+ provider: provider,
52
+ lease_id: lease_id,
53
+ expires_at: expires_at&.iso8601,
54
+ renewable: renewable,
55
+ issued_at: issued_at&.iso8601,
56
+ ttl: ttl_seconds,
57
+ valid: valid?,
58
+ metadata: metadata
59
+ }
60
+ end
61
+ end
62
+ end
63
+ end