legionio 1.4.104 → 1.4.105

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: 12afbe73936b06db9067ffa5e4fc445fe52c76687cd13aedbdb4b390a5515d7f
4
- data.tar.gz: 960ca1400fe432d4ee1dbe51658afaa2e988982b178da080b8f20795bc42fa7e
3
+ metadata.gz: bc07c77ea1337b17cf9a4be55e6469e9273beaa182e11aa7afac08405fe70275
4
+ data.tar.gz: e1666f8f6255c3a5cfd418357435fc407d5261be55621ae2cd875e01590c8fb0
5
5
  SHA512:
6
- metadata.gz: 1ead15dbef330a1329a4b5a7b973235fa0d45d84b95cbaf5d7e0415ebde0d19154ef836fc6a84753780dd0270595bc087159a821b5cf06ed737b911a5707a054
7
- data.tar.gz: 61edda120bb39a6868df425f5acb2ba39e602708c9959c5c4ad54ad8d6310ee15e72bbcb024f38907cc46136b5d28548af45e4752cb66df6b46b676fcb6a8a45
6
+ metadata.gz: 893094d33f99257ec0c2066a77cb680bc44ad6bb952e0ac7fa8a2099729b2a9643043e02bbb07bf7f638e9e0f22f101c35527e3c15578c241aa796ec241b85d8
7
+ data.tar.gz: 59400eb852207387b9f7df6d22f2f3668ae52a2d5b578e935a11277622029096f51184f8212b7a13cb18a7f733067c652c57d6b09f0e3f6bd1ffc53cbce283f8
data/.rubocop.yml CHANGED
@@ -45,6 +45,7 @@ Metrics/BlockLength:
45
45
  - 'lib/legion/cli/image_command.rb'
46
46
  - 'lib/legion/cli/notebook_command.rb'
47
47
  - 'lib/legion/api/acp.rb'
48
+ - 'lib/legion/api/auth_saml.rb'
48
49
 
49
50
  Metrics/AbcSize:
50
51
  Max: 60
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.4.105] - 2026-03-21
4
+
5
+ ### Added
6
+ - `Legion::API::Routes::AuthSaml` — SAML 2.0 SP authentication flow
7
+ - `GET /api/auth/saml/metadata` — generates SP metadata XML (delegates to `OneLogin::RubySaml::Metadata`)
8
+ - `GET /api/auth/saml/login` — initiates IdP redirect via `OneLogin::RubySaml::Authrequest`
9
+ - `POST /api/auth/saml/acs` — validates SAML assertion, extracts claims (nameid, email, displayName, groups), maps groups to Legion RBAC roles, and issues a Legion JWT
10
+ - Routes are only registered when `OneLogin::RubySaml` is defined and `auth.saml.enabled` is true
11
+ - Claims mapping delegates to `Legion::Rbac::ClaimsMapper.groups_to_roles` when available, falls back to `['worker']`
12
+ - Configuration via `Legion::Settings.dig(:auth, :saml)` — keys: `idp_sso_url`, `idp_cert`, `sp_entity_id`, `sp_acs_url`, `group_map`, `default_role`, `want_assertions_signed`, `want_assertions_encrypted`
13
+ - `Legion::Registry` review workflow: `submit_for_review`, `approve`, `reject`, `deprecate`, `pending_reviews`, `usage_stats` class methods
14
+ - `Legion::Registry::Entry` gains `status`, `review_notes`, `reject_reason`, `successor`, `sunset_date`, and timestamp fields; `deprecated?` and `pending_review?` predicates
15
+ - `legion marketplace submit NAME` — submit extension for review
16
+ - `legion marketplace review` — list extensions pending review
17
+ - `legion marketplace approve NAME [--notes TEXT]` — approve an extension
18
+ - `legion marketplace reject NAME [--reason TEXT]` — reject an extension
19
+ - `legion marketplace deprecate NAME [--successor NAME] [--sunset-date DATE]` — mark extension as deprecated
20
+ - `legion marketplace stats NAME` — show usage statistics (install count, active instances, downloads)
21
+ - `Legion::API::Routes::Marketplace` — full REST API: `GET /api/marketplace`, `GET /api/marketplace/:name`, `POST /api/marketplace/:name/submit`, `POST /api/marketplace/:name/approve`, `POST /api/marketplace/:name/reject`, `POST /api/marketplace/:name/deprecate`, `GET /api/marketplace/:name/stats`
22
+ - 123 new specs across registry, CLI, and API layers
23
+
3
24
  ## [1.4.104] - 2026-03-21
4
25
 
5
26
  ### Added
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module Routes
6
+ module AuthSaml
7
+ def self.registered(app)
8
+ return unless saml_enabled?
9
+
10
+ app.helpers do
11
+ define_method(:saml_settings) { Routes::AuthSaml.build_saml_settings }
12
+ end
13
+
14
+ register_metadata(app)
15
+ register_login(app)
16
+ register_acs(app)
17
+ end
18
+
19
+ def self.saml_enabled?
20
+ return false unless defined?(OneLogin::RubySaml)
21
+
22
+ cfg = resolve_saml_config
23
+ cfg.is_a?(Hash) && cfg[:enabled]
24
+ end
25
+
26
+ def self.resolve_saml_config
27
+ return {} unless defined?(Legion::Settings)
28
+
29
+ auth = Legion::Settings[:auth]
30
+ saml = auth.is_a?(Hash) ? auth[:saml] : nil
31
+ return saml if saml.is_a?(Hash)
32
+
33
+ {}
34
+ rescue StandardError
35
+ {}
36
+ end
37
+
38
+ def self.build_saml_settings
39
+ cfg = resolve_saml_config
40
+
41
+ settings = OneLogin::RubySaml::Settings.new
42
+ settings.idp_sso_service_url = cfg[:idp_sso_url]
43
+ settings.idp_cert = cfg[:idp_cert]
44
+ settings.sp_entity_id = cfg[:sp_entity_id]
45
+ settings.assertion_consumer_service_url = cfg[:sp_acs_url]
46
+ settings.name_identifier_format = cfg[:name_id_format] ||
47
+ 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
48
+ settings.security[:authn_requests_signed] = false
49
+ settings.security[:want_assertions_signed] = cfg.fetch(:want_assertions_signed, true)
50
+ settings.security[:want_assertions_encrypted] = cfg.fetch(:want_assertions_encrypted, false)
51
+ settings
52
+ end
53
+
54
+ def self.register_metadata(app)
55
+ app.get '/api/auth/saml/metadata' do
56
+ halt 503, json_error('saml_not_configured', 'SAML SP is not configured', status_code: 503) unless defined?(OneLogin::RubySaml)
57
+
58
+ meta = OneLogin::RubySaml::Metadata.new
59
+ content_type 'application/xml'
60
+ meta.generate(saml_settings, true)
61
+ end
62
+ end
63
+
64
+ def self.register_login(app)
65
+ app.get '/api/auth/saml/login' do
66
+ halt 503, json_error('saml_not_configured', 'SAML SP is not configured', status_code: 503) unless defined?(OneLogin::RubySaml)
67
+
68
+ cfg = Routes::AuthSaml.resolve_saml_config
69
+ unless cfg[:idp_sso_url] && cfg[:sp_entity_id]
70
+ halt 500, json_error('saml_misconfigured', 'auth.saml.idp_sso_url and sp_entity_id are required',
71
+ status_code: 500)
72
+ end
73
+
74
+ auth_request = OneLogin::RubySaml::Authrequest.new
75
+ redirect auth_request.create(saml_settings)
76
+ end
77
+ end
78
+
79
+ def self.register_acs(app)
80
+ app.post '/api/auth/saml/acs' do
81
+ halt 503, json_error('saml_not_configured', 'SAML SP is not configured', status_code: 503) unless defined?(OneLogin::RubySaml)
82
+
83
+ unless params['SAMLResponse']
84
+ halt 400, json_error('missing_saml_response', 'SAMLResponse parameter is required',
85
+ status_code: 400)
86
+ end
87
+
88
+ response = OneLogin::RubySaml::Response.new(
89
+ params['SAMLResponse'],
90
+ settings: saml_settings
91
+ )
92
+
93
+ unless response.is_valid?
94
+ errors = response.errors.join(', ')
95
+ halt 401, json_error('saml_invalid', "SAML assertion is invalid: #{errors}", status_code: 401)
96
+ end
97
+
98
+ claims = Routes::AuthSaml.extract_claims(response)
99
+ roles = Routes::AuthSaml.map_roles(claims[:groups])
100
+
101
+ ttl = 28_800
102
+ token = Legion::API::Token.issue_human_token(
103
+ msid: claims[:nameid],
104
+ name: claims[:display_name],
105
+ roles: roles,
106
+ ttl: ttl
107
+ )
108
+
109
+ json_response({
110
+ access_token: token,
111
+ token_type: 'Bearer',
112
+ expires_in: ttl,
113
+ roles: roles,
114
+ name: claims[:display_name]
115
+ })
116
+ end
117
+ end
118
+
119
+ def self.extract_claims(response)
120
+ attrs = response.attributes
121
+
122
+ email = first_attr(attrs, 'email', 'mail', 'emailAddress',
123
+ 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress')
124
+ display_name = first_attr(attrs, 'displayName', 'name',
125
+ 'http://schemas.microsoft.com/identity/claims/displayname',
126
+ 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name')
127
+ groups = multi_attr(attrs, 'groups',
128
+ 'http://schemas.microsoft.com/ws/2008/06/identity/claims/groups')
129
+
130
+ {
131
+ nameid: response.nameid,
132
+ email: email,
133
+ display_name: display_name || email,
134
+ groups: groups
135
+ }
136
+ end
137
+
138
+ def self.map_roles(groups)
139
+ if defined?(Legion::Rbac::ClaimsMapper) && Legion::Rbac::ClaimsMapper.respond_to?(:groups_to_roles)
140
+ cfg = resolve_saml_config
141
+ group_map = cfg[:group_map] || {}
142
+ default_role = cfg[:default_role] || 'worker'
143
+ Legion::Rbac::ClaimsMapper.groups_to_roles(groups, group_map: group_map, default_role: default_role)
144
+ else
145
+ ['worker']
146
+ end
147
+ end
148
+
149
+ class << self
150
+ private
151
+
152
+ def first_attr(attrs, *names)
153
+ names.each do |n|
154
+ v = safe_attr(attrs, n)
155
+ return v if v
156
+ end
157
+ nil
158
+ end
159
+
160
+ def multi_attr(attrs, *names)
161
+ names.each do |n|
162
+ v = attrs.multi(n)
163
+ return Array(v) if v
164
+ rescue StandardError
165
+ nil
166
+ end
167
+ []
168
+ end
169
+
170
+ def safe_attr(attrs, name)
171
+ attrs[name]
172
+ rescue StandardError
173
+ nil
174
+ end
175
+
176
+ private :register_metadata, :register_login, :register_acs
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'legion/registry'
5
+
6
+ module Legion
7
+ class API < Sinatra::Base
8
+ module Routes
9
+ module Marketplace
10
+ module Helpers
11
+ def parse_sunset_date(date_str)
12
+ return nil if date_str.nil? || date_str.empty?
13
+
14
+ Date.parse(date_str.to_s)
15
+ rescue ArgumentError
16
+ nil
17
+ end
18
+ end
19
+
20
+ def self.registered(app)
21
+ app.helpers Helpers
22
+ register_collection(app)
23
+ register_member(app)
24
+ register_review_actions(app)
25
+ register_stats(app)
26
+ end
27
+
28
+ def self.register_collection(app)
29
+ app.get '/api/marketplace' do
30
+ query = params[:q] || params[:query]
31
+ entries = query ? Legion::Registry.search(query) : Legion::Registry.all
32
+ entries = entries.select { |e| e.status.to_s == params[:status] } if params[:status]
33
+ entries = entries.select { |e| e.risk_tier == params[:tier] } if params[:tier]
34
+
35
+ paginated = entries.slice((page_offset)..(page_offset + page_limit - 1)) || []
36
+ content_type :json
37
+ status 200
38
+ Legion::JSON.dump({
39
+ data: paginated.map(&:to_h),
40
+ meta: response_meta.merge(total: entries.size, limit: page_limit, offset: page_offset)
41
+ })
42
+ end
43
+ end
44
+
45
+ def self.register_member(app)
46
+ app.get '/api/marketplace/:name' do
47
+ entry = Legion::Registry.lookup(params[:name])
48
+ unless entry
49
+ halt 404, { 'Content-Type' => 'application/json' },
50
+ Legion::JSON.dump({ error: { code: 'not_found', message: "Extension #{params[:name]} not found" } })
51
+ end
52
+
53
+ json_response(entry.to_h.merge(stats: Legion::Registry.usage_stats(params[:name])))
54
+ end
55
+ end
56
+
57
+ def self.register_review_actions(app) # rubocop:disable Metrics/AbcSize
58
+ app.post '/api/marketplace/:name/submit' do
59
+ begin
60
+ Legion::Registry.submit_for_review(params[:name])
61
+ rescue ArgumentError => e
62
+ halt 404, { 'Content-Type' => 'application/json' },
63
+ Legion::JSON.dump({ error: { code: 'not_found', message: e.message } })
64
+ end
65
+ json_response({ name: params[:name], status: 'pending_review' }, status_code: 202)
66
+ end
67
+
68
+ app.post '/api/marketplace/:name/approve' do
69
+ body = parse_request_body
70
+ begin
71
+ Legion::Registry.approve(params[:name], notes: body[:notes])
72
+ rescue ArgumentError => e
73
+ halt 404, { 'Content-Type' => 'application/json' },
74
+ Legion::JSON.dump({ error: { code: 'not_found', message: e.message } })
75
+ end
76
+ entry = Legion::Registry.lookup(params[:name])
77
+ json_response({ name: params[:name], status: 'approved', entry: entry.to_h })
78
+ end
79
+
80
+ app.post '/api/marketplace/:name/reject' do
81
+ body = parse_request_body
82
+ begin
83
+ Legion::Registry.reject(params[:name], reason: body[:reason])
84
+ rescue ArgumentError => e
85
+ halt 404, { 'Content-Type' => 'application/json' },
86
+ Legion::JSON.dump({ error: { code: 'not_found', message: e.message } })
87
+ end
88
+ entry = Legion::Registry.lookup(params[:name])
89
+ json_response({ name: params[:name], status: 'rejected', entry: entry.to_h })
90
+ end
91
+
92
+ app.post '/api/marketplace/:name/deprecate' do
93
+ body = parse_request_body
94
+ sunset = begin
95
+ body[:sunset_date] ? Date.parse(body[:sunset_date].to_s) : nil
96
+ rescue ArgumentError
97
+ nil
98
+ end
99
+ begin
100
+ Legion::Registry.deprecate(params[:name], successor: body[:successor], sunset_date: sunset)
101
+ rescue ArgumentError => e
102
+ halt 404, { 'Content-Type' => 'application/json' },
103
+ Legion::JSON.dump({ error: { code: 'not_found', message: e.message } })
104
+ end
105
+ entry = Legion::Registry.lookup(params[:name])
106
+ json_response({ name: params[:name], status: 'deprecated', entry: entry.to_h })
107
+ end
108
+ end
109
+
110
+ def self.register_stats(app)
111
+ app.get '/api/marketplace/:name/stats' do
112
+ data = Legion::Registry.usage_stats(params[:name])
113
+ unless data
114
+ halt 404, { 'Content-Type' => 'application/json' },
115
+ Legion::JSON.dump({ error: { code: 'not_found', message: "Extension #{params[:name]} not found" } })
116
+ end
117
+
118
+ json_response(data)
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
data/lib/legion/api.rb CHANGED
@@ -30,6 +30,7 @@ require_relative 'api/rbac'
30
30
  require_relative 'api/auth'
31
31
  require_relative 'api/auth_worker'
32
32
  require_relative 'api/auth_human'
33
+ require_relative 'api/auth_saml'
33
34
  require_relative 'api/capacity'
34
35
  require_relative 'api/audit'
35
36
  require_relative 'api/metrics'
@@ -40,6 +41,7 @@ require_relative 'api/workflow'
40
41
  require_relative 'api/governance'
41
42
  require_relative 'api/acp'
42
43
  require_relative 'api/prompts'
44
+ require_relative 'api/marketplace'
43
45
 
44
46
  module Legion
45
47
  class API < Sinatra::Base
@@ -114,6 +116,7 @@ module Legion
114
116
  register Routes::Auth
115
117
  register Routes::AuthWorker
116
118
  register Routes::AuthHuman
119
+ register Routes::AuthSaml
117
120
  register Routes::Capacity
118
121
  register Routes::Audit
119
122
  register Routes::Metrics
@@ -123,6 +126,7 @@ module Legion
123
126
  register Routes::Governance
124
127
  register Routes::Acp
125
128
  register Routes::Prompts
129
+ register Routes::Marketplace
126
130
 
127
131
  use Legion::API::Middleware::RequestLogger
128
132
  use Legion::Rbac::Middleware if defined?(Legion::Rbac::Middleware)
@@ -9,70 +9,285 @@ module Legion
9
9
  true
10
10
  end
11
11
 
12
+ class_option :json, type: :boolean, default: false, desc: 'Output as JSON'
13
+ class_option :no_color, type: :boolean, default: false, desc: 'Disable color output'
14
+
15
+ # ──────────────────────────────────────────────────────────
16
+ # search
17
+ # ──────────────────────────────────────────────────────────
18
+
12
19
  desc 'search QUERY', 'Search extension registry'
13
20
  def search(query)
14
21
  require 'legion/registry'
22
+ out = formatter
15
23
  results = Legion::Registry.search(query)
24
+
16
25
  if results.empty?
17
- say "No extensions found matching '#{query}'", :yellow
26
+ out.warn("No extensions found matching '#{query}'")
18
27
  return
19
28
  end
20
29
 
21
- say "Found #{results.size} extension(s):", :green
22
- results.each do |e|
23
- status = e.approved? ? '[approved]' : "[#{e.airb_status}]"
24
- say " #{e.name.ljust(25)} #{e.version.to_s.ljust(10)} #{status} #{e.description}"
30
+ if options[:json]
31
+ out.json(results.map(&:to_h))
32
+ else
33
+ rows = results.map do |e|
34
+ status_label = e.approved? ? 'approved' : (e.status || e.airb_status).to_s
35
+ [e.name, e.version.to_s, status_label, (e.description || '')[0..60]]
36
+ end
37
+ out.table(%w[Name Version Status Description], rows)
25
38
  end
26
39
  end
27
40
 
41
+ # ──────────────────────────────────────────────────────────
42
+ # info
43
+ # ──────────────────────────────────────────────────────────
44
+
28
45
  desc 'info NAME', 'Show extension details'
29
46
  def info(name)
30
47
  require 'legion/registry'
48
+ out = formatter
31
49
  entry = Legion::Registry.lookup(name)
50
+
32
51
  unless entry
33
- say "Extension '#{name}' not found", :red
52
+ out.error("Extension '#{name}' not found")
34
53
  return
35
54
  end
36
55
 
37
- entry.to_h.each { |k, v| say " #{k}: #{v}" }
56
+ if options[:json]
57
+ out.json(entry.to_h)
58
+ else
59
+ out.header("Extension: #{entry.name}")
60
+ out.spacer
61
+ out.detail(entry.to_h.compact)
62
+ end
38
63
  end
39
64
 
65
+ # ──────────────────────────────────────────────────────────
66
+ # list
67
+ # ──────────────────────────────────────────────────────────
68
+
40
69
  desc 'list', 'List all registered extensions'
41
70
  option :approved, type: :boolean, desc: 'Show only approved extensions'
42
- option :tier, type: :string, desc: 'Filter by risk tier'
71
+ option :tier, type: :string, desc: 'Filter by risk tier'
72
+ option :status, type: :string, desc: 'Filter by review status'
43
73
  def list
44
74
  require 'legion/registry'
45
- extensions = if options[:approved]
46
- Legion::Registry.approved
47
- elsif options[:tier]
48
- Legion::Registry.by_risk_tier(options[:tier])
49
- else
50
- Legion::Registry.all
51
- end
75
+ out = formatter
76
+ extensions = build_extension_list
52
77
 
53
78
  if extensions.empty?
54
- say 'No extensions registered', :yellow
79
+ out.warn('No extensions registered')
55
80
  return
56
81
  end
57
82
 
58
- say "#{extensions.size} extension(s):", :green
59
- extensions.each do |e|
60
- say " #{e.name.ljust(25)} #{e.version.to_s.ljust(10)} [#{e.risk_tier}]"
83
+ if options[:json]
84
+ out.json(extensions.map(&:to_h))
85
+ else
86
+ rows = extensions.map { |e| [e.name, e.version.to_s, e.status.to_s, e.risk_tier] }
87
+ out.table(%w[Name Version Status Tier], rows)
88
+ puts " #{extensions.size} extension(s)"
61
89
  end
62
90
  end
63
91
 
92
+ # ──────────────────────────────────────────────────────────
93
+ # scan
94
+ # ──────────────────────────────────────────────────────────
95
+
64
96
  desc 'scan NAME', 'Run security scan on extension'
65
97
  def scan(name)
66
98
  require 'legion/registry/security_scanner'
99
+ out = formatter
67
100
  scanner = Legion::Registry::SecurityScanner.new
68
- result = scanner.scan(name: name)
101
+ result = scanner.scan(name: name)
69
102
 
70
- result[:checks].each do |check|
71
- color = check[:status] == :fail ? :red : :green
72
- say " #{check[:check]}: #{check[:status]} - #{check[:details]}", color
103
+ if options[:json]
104
+ out.json(result)
105
+ else
106
+ result[:checks].each do |check|
107
+ color = check[:status] == :fail ? :critical : :nominal
108
+ puts " #{out.colorize(check[:check].to_s.ljust(25), color)} #{check[:status]} - #{check[:details]}"
109
+ end
110
+ if result[:passed]
111
+ out.success('Scan PASSED')
112
+ else
113
+ out.error('Scan FAILED')
114
+ end
73
115
  end
116
+ end
117
+
118
+ # ──────────────────────────────────────────────────────────
119
+ # submit
120
+ # ──────────────────────────────────────────────────────────
121
+
122
+ desc 'submit NAME', 'Submit extension for review'
123
+ def submit(name)
124
+ require 'legion/registry'
125
+ out = formatter
126
+
127
+ Legion::Registry.submit_for_review(name)
128
+
129
+ if options[:json]
130
+ out.json(success: true, name: name, status: 'pending_review')
131
+ else
132
+ out.success("'#{name}' submitted for review")
133
+ end
134
+ rescue ArgumentError => e
135
+ out.error(e.message)
136
+ end
74
137
 
75
- say result[:passed] ? 'PASSED' : 'FAILED', result[:passed] ? :green : :red
138
+ # ──────────────────────────────────────────────────────────
139
+ # review
140
+ # ──────────────────────────────────────────────────────────
141
+
142
+ desc 'review', 'List extensions pending review'
143
+ def review
144
+ require 'legion/registry'
145
+ out = formatter
146
+ pending = Legion::Registry.pending_reviews
147
+
148
+ if pending.empty?
149
+ out.warn('No extensions pending review')
150
+ return
151
+ end
152
+
153
+ if options[:json]
154
+ out.json(pending.map(&:to_h))
155
+ else
156
+ rows = pending.map { |e| [e.name, e.version.to_s, e.author.to_s, e.submitted_at.to_s] }
157
+ out.table(%w[Name Version Author Submitted], rows)
158
+ puts " #{pending.size} pending review(s)"
159
+ end
160
+ end
161
+
162
+ # ──────────────────────────────────────────────────────────
163
+ # approve
164
+ # ──────────────────────────────────────────────────────────
165
+
166
+ desc 'approve NAME', 'Approve an extension'
167
+ option :notes, type: :string, desc: 'Reviewer notes'
168
+ def approve(name)
169
+ require 'legion/registry'
170
+ out = formatter
171
+
172
+ Legion::Registry.approve(name, notes: options[:notes])
173
+
174
+ if options[:json]
175
+ out.json(success: true, name: name, status: 'approved')
176
+ else
177
+ out.success("'#{name}' approved")
178
+ out.detail({ 'Notes' => options[:notes] }) if options[:notes]
179
+ end
180
+ rescue ArgumentError => e
181
+ out.error(e.message)
182
+ end
183
+
184
+ # ──────────────────────────────────────────────────────────
185
+ # reject
186
+ # ──────────────────────────────────────────────────────────
187
+
188
+ desc 'reject NAME', 'Reject an extension'
189
+ option :reason, type: :string, desc: 'Rejection reason'
190
+ def reject(name)
191
+ require 'legion/registry'
192
+ out = formatter
193
+
194
+ Legion::Registry.reject(name, reason: options[:reason])
195
+
196
+ if options[:json]
197
+ out.json(success: true, name: name, status: 'rejected')
198
+ else
199
+ out.success("'#{name}' rejected")
200
+ out.detail({ 'Reason' => options[:reason] }) if options[:reason]
201
+ end
202
+ rescue ArgumentError => e
203
+ out.error(e.message)
204
+ end
205
+
206
+ # ──────────────────────────────────────────────────────────
207
+ # deprecate
208
+ # ──────────────────────────────────────────────────────────
209
+
210
+ desc 'deprecate NAME', 'Mark an extension as deprecated'
211
+ option :successor, type: :string, desc: 'Replacement extension name'
212
+ option :sunset_date, type: :string, desc: 'Sunset date (YYYY-MM-DD)'
213
+ def deprecate(name)
214
+ require 'legion/registry'
215
+ out = formatter
216
+
217
+ sunset = parse_sunset_date(options[:sunset_date])
218
+ Legion::Registry.deprecate(name, successor: options[:successor], sunset_date: sunset)
219
+
220
+ if options[:json]
221
+ out.json(success: true, name: name, status: 'deprecated',
222
+ successor: options[:successor], sunset_date: options[:sunset_date])
223
+ else
224
+ out.success("'#{name}' marked as deprecated")
225
+ detail_hash = {}
226
+ detail_hash['Successor'] = options[:successor] if options[:successor]
227
+ detail_hash['Sunset Date'] = options[:sunset_date] if options[:sunset_date]
228
+ out.detail(detail_hash) unless detail_hash.empty?
229
+ end
230
+ rescue ArgumentError => e
231
+ out.error(e.message)
232
+ end
233
+
234
+ # ──────────────────────────────────────────────────────────
235
+ # stats
236
+ # ──────────────────────────────────────────────────────────
237
+
238
+ desc 'stats NAME', 'Show usage statistics for an extension'
239
+ def stats(name)
240
+ require 'legion/registry'
241
+ out = formatter
242
+ data = Legion::Registry.usage_stats(name)
243
+
244
+ unless data
245
+ out.error("Extension '#{name}' not found")
246
+ return
247
+ end
248
+
249
+ if options[:json]
250
+ out.json(data)
251
+ else
252
+ out.header("Usage Stats: #{name}")
253
+ out.spacer
254
+ out.detail({
255
+ 'Install Count' => data[:install_count].to_s,
256
+ 'Active Instances' => data[:active_instances].to_s,
257
+ 'Downloads (7d)' => data[:downloads_7d].to_s,
258
+ 'Downloads (30d)' => data[:downloads_30d].to_s,
259
+ 'Last Updated' => data[:last_updated].to_s
260
+ })
261
+ end
262
+ end
263
+
264
+ no_commands do
265
+ def formatter
266
+ @formatter ||= Output::Formatter.new(
267
+ json: options[:json],
268
+ color: !options[:no_color]
269
+ )
270
+ end
271
+
272
+ def build_extension_list
273
+ if options[:approved]
274
+ Legion::Registry.approved
275
+ elsif options[:tier]
276
+ Legion::Registry.by_risk_tier(options[:tier])
277
+ elsif options[:status]
278
+ Legion::Registry.all.select { |e| e.status.to_s == options[:status] }
279
+ else
280
+ Legion::Registry.all
281
+ end
282
+ end
283
+
284
+ def parse_sunset_date(date_str)
285
+ return nil if date_str.nil? || date_str.empty?
286
+
287
+ Date.parse(date_str)
288
+ rescue ArgumentError
289
+ nil
290
+ end
76
291
  end
77
292
  end
78
293
  end
@@ -2,24 +2,37 @@
2
2
 
3
3
  module Legion
4
4
  module Registry
5
+ VALID_STATUSES = %i[pending_review approved rejected deprecated sunset active].freeze
6
+
5
7
  class Entry
6
8
  ATTRS = %i[name version author risk_tier permissions airb_status
7
- description homepage checksum capabilities].freeze
9
+ description homepage checksum capabilities
10
+ status review_notes reject_reason successor sunset_date
11
+ submitted_at approved_at rejected_at deprecated_at].freeze
8
12
 
9
13
  attr_reader(*ATTRS)
10
14
 
11
15
  def initialize(**attrs)
12
16
  ATTRS.each { |a| instance_variable_set(:"@#{a}", attrs[a]) }
13
- @risk_tier ||= 'low'
17
+ @risk_tier ||= 'low'
14
18
  @airb_status ||= 'pending'
15
19
  @capabilities ||= []
16
- @permissions ||= []
20
+ @permissions ||= []
21
+ @status ||= :active
17
22
  end
18
23
 
19
24
  def approved?
20
25
  airb_status == 'approved'
21
26
  end
22
27
 
28
+ def deprecated?
29
+ %i[deprecated sunset].include?(status)
30
+ end
31
+
32
+ def pending_review?
33
+ status == :pending_review
34
+ end
35
+
23
36
  def to_h
24
37
  ATTRS.to_h { |a| [a, send(a)] }
25
38
  end
@@ -62,11 +75,79 @@ module Legion
62
75
  @store = {}
63
76
  end
64
77
 
78
+ # Review workflow
79
+
80
+ def submit_for_review(name)
81
+ entry = find_or_raise(name)
82
+ update_entry(name, entry, status: :pending_review, submitted_at: Time.now.utc)
83
+ true
84
+ end
85
+
86
+ def approve(name, notes: nil)
87
+ entry = find_or_raise(name)
88
+ update_entry(name, entry,
89
+ status: :approved,
90
+ airb_status: 'approved',
91
+ review_notes: notes,
92
+ approved_at: Time.now.utc)
93
+ true
94
+ end
95
+
96
+ def reject(name, reason: nil)
97
+ entry = find_or_raise(name)
98
+ update_entry(name, entry,
99
+ status: :rejected,
100
+ reject_reason: reason,
101
+ rejected_at: Time.now.utc)
102
+ true
103
+ end
104
+
105
+ def deprecate(name, successor: nil, sunset_date: nil)
106
+ entry = find_or_raise(name)
107
+ update_entry(name, entry,
108
+ status: :deprecated,
109
+ successor: successor,
110
+ sunset_date: sunset_date,
111
+ deprecated_at: Time.now.utc)
112
+ true
113
+ end
114
+
115
+ def pending_reviews
116
+ store.values.select(&:pending_review?)
117
+ end
118
+
119
+ def usage_stats(name)
120
+ entry = lookup(name.to_s)
121
+ return nil unless entry
122
+
123
+ {
124
+ name: entry.name,
125
+ version: entry.version,
126
+ install_count: 0,
127
+ active_instances: 0,
128
+ last_updated: nil,
129
+ downloads_7d: 0,
130
+ downloads_30d: 0
131
+ }
132
+ end
133
+
65
134
  private
66
135
 
67
136
  def store
68
137
  @store ||= {}
69
138
  end
139
+
140
+ def find_or_raise(name)
141
+ entry = lookup(name.to_s)
142
+ raise ArgumentError, "Extension '#{name}' not found in registry" unless entry
143
+
144
+ entry
145
+ end
146
+
147
+ def update_entry(name, entry, **overrides)
148
+ attrs = entry.to_h.merge(overrides)
149
+ store[name.to_s] = Entry.new(**attrs)
150
+ end
70
151
  end
71
152
  end
72
153
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.4.104'
4
+ VERSION = '1.4.105'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.104
4
+ version: 1.4.105
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -357,6 +357,7 @@ files:
357
357
  - lib/legion/api/audit.rb
358
358
  - lib/legion/api/auth.rb
359
359
  - lib/legion/api/auth_human.rb
360
+ - lib/legion/api/auth_saml.rb
360
361
  - lib/legion/api/auth_worker.rb
361
362
  - lib/legion/api/capacity.rb
362
363
  - lib/legion/api/catalog.rb
@@ -370,6 +371,7 @@ files:
370
371
  - lib/legion/api/hooks.rb
371
372
  - lib/legion/api/lex.rb
372
373
  - lib/legion/api/llm.rb
374
+ - lib/legion/api/marketplace.rb
373
375
  - lib/legion/api/metrics.rb
374
376
  - lib/legion/api/middleware/api_version.rb
375
377
  - lib/legion/api/middleware/auth.rb