legionio 1.4.105 → 1.4.107

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: bc07c77ea1337b17cf9a4be55e6469e9273beaa182e11aa7afac08405fe70275
4
- data.tar.gz: e1666f8f6255c3a5cfd418357435fc407d5261be55621ae2cd875e01590c8fb0
3
+ metadata.gz: 852b32f5818f330941b385f4298e266c2906ff506f8111c58b708be29f83b707
4
+ data.tar.gz: 7a1ba8bfb20275e5e6fec4c89658c501cf81a63db55e873c9a6c08c0cffaba70
5
5
  SHA512:
6
- metadata.gz: 893094d33f99257ec0c2066a77cb680bc44ad6bb952e0ac7fa8a2099729b2a9643043e02bbb07bf7f638e9e0f22f101c35527e3c15578c241aa796ec241b85d8
7
- data.tar.gz: 59400eb852207387b9f7df6d22f2f3668ae52a2d5b578e935a11277622029096f51184f8212b7a13cb18a7f733067c652c57d6b09f0e3f6bd1ffc53cbce283f8
6
+ metadata.gz: e8fd883eb244d806bc51a223ff300d12e06d160c8b5096a6e34898dd1f7c0577345c1e2d30f51e168d20da438d28108934cf57645df180c156f91c107d4eb964
7
+ data.tar.gz: e133ef05dd03f998185d2aa00fe1fe679a69f9e31db58b11987c8bff75ce2f9ed7baedcc4745ebb3a39362d228e1ca33943897b396dd585ce4981d83d1802709
data/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.4.107] - 2026-03-21
4
+
5
+ ### Added
6
+ - `Legion::Docs::SiteGenerator` — full static site generator with kramdown + rouge syntax highlighting
7
+ - Converts markdown guides to HTML with navigation sidebar and styled template
8
+ - CLI reference auto-generation via Thor command introspection
9
+ - Extension reference auto-generation via Bundler gem discovery
10
+ - `Legion::CLI::Docs` — `legion docs generate` and `legion docs serve` subcommands
11
+ - 39 new specs (2248 total, 0 failures)
12
+
13
+ ## [1.4.106] - 2026-03-21
14
+
15
+ ### Added
16
+ - `Legion::DigitalWorker::Registration` module with full approval workflow: `register`, `approve`, `reject`, `pending_approvals`, `approval_required?`, and `escalate`
17
+ - Workers with `high` or `critical` risk tiers are created in `pending_approval` state instead of `bootstrap`, triggering an AIRB intake
18
+ - `Legion::DigitalWorker::Airb` module for AIRB integration: `create_intake`, `check_status`, `sync_status` (mock API by default; live API activated via `Legion::Settings.dig(:airb)`)
19
+ - New lifecycle states `pending_approval` and `rejected` in `Lifecycle::TRANSITIONS`, with appropriate `EXTINCTION_MAPPING` and `CONSENT_MAPPING` entries
20
+ - Transition rules: `pending_approval -> active` (approve), `pending_approval -> rejected` (reject)
21
+ - CLI subcommands: `legion worker approvals`, `legion worker approve ID [--notes TEXT]`, `legion worker reject ID --reason TEXT`
22
+ - API routes: `GET /api/workers/approvals`, `POST /api/workers/:id/approve`, `POST /api/workers/:id/reject`
23
+ - 37 new specs across `registration_spec.rb` (28 examples) and `airb_spec.rb` (9 examples)
24
+ - `Legion::Phi` module — HIPAA/BAA PHI tagging and tracking: `PHI_TAG`, `tag`, `tagged?`, `phi_fields`, `redact`, `erase`, `auto_detect_fields`
25
+ - `Legion::Phi::AccessLog` module — PHI access audit trail: `log_access`, `log_access!`, `recent_access`; integrates with `Legion::Audit` when available, falls back to `Legion::Logging`
26
+ - `Legion::Phi::Erasure` module — cryptographic erasure: `erase_record` (AES-256-GCM with throwaway key), `erase_for_subject` (HIPAA right to deletion), `erasure_log`
27
+ - Pattern-based auto-detection of PHI fields (ssn, mrn, dob, patient_name, phone, email, address, diagnosis, npi, insurance_id, etc.) via configurable regex patterns in `legion-settings` at `phi.field_patterns`
28
+ - `Legion::Crypt` guarded throughout — falls back to stdlib OpenSSL when `legion-crypt` is not loaded
29
+ - 59 new specs across `phi_spec.rb` (30 examples), `phi/access_log_spec.rb` (15 examples), `phi/erasure_spec.rb` (14 examples)
30
+ - `Legion::API::Routes::GraphQL` — GraphQL API layer using graphql-ruby (optional dependency, guarded with `defined?(GraphQL)`)
31
+ - `POST /api/graphql` — executes GraphQL queries; parses `query`, `variables`, `operationName` from JSON body
32
+ - `GET /api/graphql` — serves GraphiQL browser IDE for interactive introspection
33
+ - `Legion::API::GraphQL::Schema` — root schema with `max_depth: 10`, `max_complexity: 200`
34
+ - `Legion::API::GraphQL::Types::QueryType` — root query with `workers`, `worker`, `extensions`, `extension`, `tasks`, `node` fields and filtering arguments
35
+ - `Legion::API::GraphQL::Types::WorkerType`, `ExtensionType`, `TaskType`, `NodeType` — field definitions for each domain object
36
+ - Data resolution falls back gracefully: uses `Legion::Data` models when connected, falls back to `Legion::DigitalWorker::Registry` / `Legion::Registry` in-memory stores otherwise
37
+ - 45 new specs in `spec/legion/api/graphql_spec.rb`
38
+
3
39
  ## [1.4.105] - 2026-03-21
4
40
 
5
41
  ### Added
data/Gemfile CHANGED
@@ -4,9 +4,11 @@ source 'https://rubygems.org'
4
4
 
5
5
  gemspec
6
6
 
7
+ gem 'kramdown', '>= 2.0'
7
8
  gem 'mysql2'
8
9
 
9
10
  group :test do
11
+ gem 'graphql'
10
12
  gem 'rack-test'
11
13
  gem 'rake'
12
14
  gem 'rspec'
data/legionio.gemspec CHANGED
@@ -36,6 +36,8 @@ Gem::Specification.new do |spec|
36
36
 
37
37
  spec.add_dependency 'legion-mcp'
38
38
 
39
+ spec.add_dependency 'kramdown', '>= 2.0'
40
+
39
41
  spec.add_dependency 'bootsnap', '>= 1.18'
40
42
  spec.add_dependency 'concurrent-ruby', '>= 1.2'
41
43
  spec.add_dependency 'concurrent-ruby-ext', '>= 1.2'
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module GraphQL
6
+ module Resolvers
7
+ module Extensions
8
+ def self.resolve(status: nil)
9
+ if defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection
10
+ resolve_from_data(status: status)
11
+ else
12
+ resolve_from_registry(status: status)
13
+ end
14
+ end
15
+
16
+ def self.find(name:)
17
+ resolve.find { |e| e[:name] == name }
18
+ end
19
+
20
+ def self.resolve_from_data(status: nil)
21
+ return [] unless defined?(Legion::Data::Model::Extension)
22
+
23
+ dataset = Legion::Data::Model::Extension.order(:id)
24
+ dataset = dataset.where(status: status) if status
25
+ dataset.all.map { |e| extension_hash(e.values) }
26
+ rescue StandardError
27
+ []
28
+ end
29
+
30
+ def self.resolve_from_registry(status: nil)
31
+ return [] unless defined?(Legion::Registry)
32
+
33
+ entries = Legion::Registry.respond_to?(:all) ? Legion::Registry.all : []
34
+ entries = entries.map { |e| e.is_a?(Hash) ? e : e.to_h }
35
+ entries = entries.select { |e| e[:status].to_s == status } if status
36
+ entries.map { |e| extension_hash(e) }
37
+ rescue StandardError
38
+ []
39
+ end
40
+
41
+ def self.extension_hash(values)
42
+ {
43
+ name: values[:name],
44
+ version: values[:version],
45
+ status: values[:status]&.to_s || 'active',
46
+ description: values[:description],
47
+ risk_tier: values[:risk_tier],
48
+ runners: Array(values[:runners])
49
+ }
50
+ end
51
+
52
+ private_class_method :resolve_from_data, :resolve_from_registry, :extension_hash
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module GraphQL
6
+ module Resolvers
7
+ module Node
8
+ def self.resolve
9
+ name = defined?(Legion::Settings) ? Legion::Settings[:client][:name] : 'legion'
10
+ version = defined?(Legion::VERSION) ? Legion::VERSION : nil
11
+ ready = defined?(Legion::Readiness) ? Legion::Readiness.ready? : true
12
+ uptime = defined?(Legion::Process) ? calculate_uptime : nil
13
+
14
+ {
15
+ name: name,
16
+ version: version,
17
+ uptime: uptime,
18
+ ready: ready
19
+ }
20
+ rescue StandardError
21
+ { name: nil, version: nil, uptime: nil, ready: false }
22
+ end
23
+
24
+ def self.calculate_uptime
25
+ return nil unless defined?(Legion::Process) &&
26
+ Legion::Process.respond_to?(:started_at) &&
27
+ Legion::Process.started_at
28
+
29
+ (Time.now.utc - Legion::Process.started_at).to_i
30
+ rescue StandardError
31
+ nil
32
+ end
33
+
34
+ private_class_method :calculate_uptime
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module GraphQL
6
+ module Resolvers
7
+ module Tasks
8
+ def self.resolve(status: nil, limit: nil)
9
+ return [] unless defined?(Legion::Data) &&
10
+ Legion::Data.respond_to?(:connection) &&
11
+ Legion::Data.connection
12
+
13
+ resolve_from_data(status: status, limit: limit)
14
+ rescue StandardError
15
+ []
16
+ end
17
+
18
+ def self.resolve_from_data(status: nil, limit: nil)
19
+ return [] unless defined?(Legion::Data::Model::Task)
20
+
21
+ dataset = Legion::Data::Model::Task.order(Sequel.desc(:id))
22
+ dataset = dataset.where(status: status) if status
23
+ dataset = dataset.limit(limit) if limit
24
+ dataset.all.map { |t| task_hash(t.values) }
25
+ rescue StandardError
26
+ []
27
+ end
28
+
29
+ def self.task_hash(values)
30
+ {
31
+ id: values[:id],
32
+ status: values[:status],
33
+ extension: values[:extension],
34
+ runner: values[:runner],
35
+ function: values[:function],
36
+ created_at: values[:created_at]&.to_s,
37
+ completed_at: values[:completed_at]&.to_s
38
+ }
39
+ end
40
+
41
+ private_class_method :resolve_from_data, :task_hash
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module GraphQL
6
+ module Resolvers
7
+ module Workers
8
+ def self.resolve(status: nil, risk_tier: nil, limit: nil)
9
+ if defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection
10
+ resolve_from_data(status: status, risk_tier: risk_tier, limit: limit)
11
+ else
12
+ resolve_from_registry(status: status, risk_tier: risk_tier, limit: limit)
13
+ end
14
+ end
15
+
16
+ def self.find(id:)
17
+ if defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection
18
+ find_from_data(id: id)
19
+ else
20
+ resolve(limit: nil).find { |w| w[:id].to_s == id.to_s }
21
+ end
22
+ end
23
+
24
+ def self.resolve_from_data(status: nil, risk_tier: nil, limit: nil)
25
+ return [] unless defined?(Legion::Data::Model::DigitalWorker)
26
+
27
+ dataset = Legion::Data::Model::DigitalWorker.order(:id)
28
+ dataset = dataset.where(lifecycle_state: status) if status
29
+ dataset = dataset.where(risk_tier: risk_tier) if risk_tier
30
+ dataset = dataset.limit(limit) if limit
31
+ dataset.all.map { |w| worker_hash(w.values) }
32
+ rescue StandardError
33
+ []
34
+ end
35
+
36
+ def self.resolve_from_registry(status: nil, risk_tier: nil, limit: nil)
37
+ workers = []
38
+
39
+ if defined?(Legion::DigitalWorker::Registry)
40
+ ids = Legion::DigitalWorker::Registry.local_worker_ids
41
+ ids.each do |wid|
42
+ workers << { id: wid, name: "worker-#{wid}", status: 'active', risk_tier: nil, team: nil, extension: nil, created_at: nil }
43
+ end
44
+ end
45
+
46
+ workers = workers.select { |w| w[:status] == status } if status
47
+ workers = workers.select { |w| w[:risk_tier] == risk_tier } if risk_tier
48
+ workers = workers.first(limit) if limit
49
+ workers
50
+ end
51
+
52
+ def self.find_from_data(id:)
53
+ return nil unless defined?(Legion::Data::Model::DigitalWorker)
54
+
55
+ worker = Legion::Data::Model::DigitalWorker.first(id: id.to_i)
56
+ worker ? worker_hash(worker.values) : nil
57
+ rescue StandardError
58
+ nil
59
+ end
60
+
61
+ def self.worker_hash(values)
62
+ {
63
+ id: values[:id],
64
+ name: values[:name],
65
+ status: values[:lifecycle_state] || values[:status],
66
+ risk_tier: values[:risk_tier],
67
+ team: values[:team],
68
+ extension: values[:extension_name],
69
+ created_at: values[:created_at]&.to_s
70
+ }
71
+ end
72
+
73
+ private_class_method :resolve_from_data, :resolve_from_registry, :find_from_data, :worker_hash
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ return unless defined?(GraphQL)
4
+
5
+ require_relative 'types/base_object'
6
+ require_relative 'types/node_type'
7
+ require_relative 'types/worker_type'
8
+ require_relative 'types/extension_type'
9
+ require_relative 'types/task_type'
10
+ require_relative 'resolvers/node'
11
+ require_relative 'resolvers/workers'
12
+ require_relative 'resolvers/extensions'
13
+ require_relative 'resolvers/tasks'
14
+ require_relative 'types/query_type'
15
+
16
+ module Legion
17
+ class API < Sinatra::Base
18
+ module GraphQL
19
+ class Schema < ::GraphQL::Schema
20
+ query Types::QueryType
21
+
22
+ max_depth 10
23
+ max_complexity 200
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ return unless defined?(GraphQL)
4
+
5
+ module Legion
6
+ class API < Sinatra::Base
7
+ module GraphQL
8
+ module Types
9
+ class BaseObject < ::GraphQL::Schema::Object
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ return unless defined?(GraphQL)
4
+
5
+ module Legion
6
+ class API < Sinatra::Base
7
+ module GraphQL
8
+ module Types
9
+ class ExtensionType < BaseObject
10
+ graphql_name 'Extension'
11
+ description 'A LegionIO extension (LEX)'
12
+
13
+ field :name, String, null: true, description: 'Extension gem name'
14
+ field :version, String, null: true, description: 'Extension version'
15
+ field :status, String, null: true, description: 'Extension status'
16
+ field :description, String, null: true, description: 'Extension description'
17
+ field :risk_tier, String, null: true, description: 'Risk classification tier'
18
+ field :runners, [String], null: true, description: 'Runner class names'
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ return unless defined?(GraphQL)
4
+
5
+ module Legion
6
+ class API < Sinatra::Base
7
+ module GraphQL
8
+ module Types
9
+ class NodeType < BaseObject
10
+ graphql_name 'Node'
11
+ description 'A LegionIO node'
12
+
13
+ field :name, String, null: true, description: 'Node name'
14
+ field :version, String, null: true, description: 'LegionIO version'
15
+ field :uptime, Integer, null: true, description: 'Uptime in seconds'
16
+ field :ready, Boolean, null: false, description: 'Whether the node is ready'
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ return unless defined?(GraphQL)
4
+
5
+ module Legion
6
+ class API < Sinatra::Base
7
+ module GraphQL
8
+ module Types
9
+ class QueryType < BaseObject
10
+ graphql_name 'Query'
11
+ description 'Root query type'
12
+
13
+ # ── workers ──────────────────────────────────────────────────────────
14
+
15
+ field :workers, [WorkerType], null: false, description: 'List digital workers' do
16
+ argument :status, String, required: false, description: 'Filter by lifecycle state'
17
+ argument :risk_tier, String, required: false, description: 'Filter by risk tier'
18
+ argument :limit, Integer, required: false, description: 'Maximum results'
19
+ end
20
+
21
+ field :worker, WorkerType, null: true, description: 'Find a digital worker by ID' do
22
+ argument :id, ID, required: true, description: 'Worker ID'
23
+ end
24
+
25
+ # ── extensions ───────────────────────────────────────────────────────
26
+
27
+ field :extensions, [ExtensionType], null: false, description: 'List loaded extensions' do
28
+ argument :status, String, required: false, description: 'Filter by status'
29
+ end
30
+
31
+ field :extension, ExtensionType, null: true, description: 'Find an extension by name' do
32
+ argument :name, String, required: true, description: 'Extension gem name'
33
+ end
34
+
35
+ # ── tasks ─────────────────────────────────────────────────────────────
36
+
37
+ field :tasks, [TaskType], null: false, description: 'List task records' do
38
+ argument :status, String, required: false, description: 'Filter by status'
39
+ argument :limit, Integer, required: false, description: 'Maximum results'
40
+ end
41
+
42
+ # ── node ──────────────────────────────────────────────────────────────
43
+
44
+ field :node, NodeType, null: true, description: 'Current node information'
45
+
46
+ # ── resolvers ────────────────────────────────────────────────────────
47
+
48
+ def workers(status: nil, risk_tier: nil, limit: nil)
49
+ Resolvers::Workers.resolve(status: status, risk_tier: risk_tier, limit: limit)
50
+ end
51
+
52
+ def worker(id:)
53
+ Resolvers::Workers.find(id: id)
54
+ end
55
+
56
+ def extensions(status: nil)
57
+ Resolvers::Extensions.resolve(status: status)
58
+ end
59
+
60
+ def extension(name:)
61
+ Resolvers::Extensions.find(name: name)
62
+ end
63
+
64
+ def tasks(status: nil, limit: nil)
65
+ Resolvers::Tasks.resolve(status: status, limit: limit)
66
+ end
67
+
68
+ def node
69
+ Resolvers::Node.resolve
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ return unless defined?(GraphQL)
4
+
5
+ module Legion
6
+ class API < Sinatra::Base
7
+ module GraphQL
8
+ module Types
9
+ class TaskType < BaseObject
10
+ graphql_name 'Task'
11
+ description 'A LegionIO task execution record'
12
+
13
+ field :id, Integer, null: true, description: 'Task database ID'
14
+ field :status, String, null: true, description: 'Task status'
15
+ field :extension, String, null: true, description: 'Extension name'
16
+ field :runner, String, null: true, description: 'Runner namespace'
17
+ field :function, String, null: true, description: 'Function name'
18
+ field :created_at, String, null: true, description: 'Creation timestamp'
19
+ field :completed_at, String, null: true, description: 'Completion timestamp'
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ return unless defined?(GraphQL)
4
+
5
+ module Legion
6
+ class API < Sinatra::Base
7
+ module GraphQL
8
+ module Types
9
+ class WorkerType < BaseObject
10
+ graphql_name 'Worker'
11
+ description 'A LegionIO digital worker'
12
+
13
+ field :id, Integer, null: true, description: 'Worker database ID'
14
+ field :name, String, null: true, description: 'Worker name'
15
+ field :status, String, null: true, description: 'Lifecycle state'
16
+ field :risk_tier, String, null: true, description: 'AIRB risk tier'
17
+ field :team, String, null: true, description: 'Team name'
18
+ field :extension, String, null: true, description: 'Extension name'
19
+ field :created_at, String, null: true, description: 'Creation timestamp'
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ return unless defined?(GraphQL)
4
+
5
+ require_relative 'graphql/schema'
6
+
7
+ module Legion
8
+ class API < Sinatra::Base
9
+ module Routes
10
+ module GraphQL
11
+ def self.registered(app)
12
+ app.post '/api/graphql' do
13
+ content_type :json
14
+
15
+ body_str = request.body.read
16
+ payload = body_str.empty? ? {} : Legion::JSON.load(body_str)
17
+ payload = payload.transform_keys(&:to_sym) if payload.is_a?(Hash)
18
+
19
+ query = payload[:query]
20
+ variables = payload[:variables] || {}
21
+ operation_name = payload[:operationName]
22
+
23
+ if query.nil? || query.strip.empty?
24
+ status 400
25
+ next Legion::JSON.dump({
26
+ errors: [{ message: 'query is required' }]
27
+ })
28
+ end
29
+
30
+ result = Legion::API::GraphQL::Schema.execute(
31
+ query,
32
+ variables: variables,
33
+ operation_name: operation_name,
34
+ context: { request: request }
35
+ )
36
+
37
+ status 200
38
+ Legion::JSON.dump(result.to_h)
39
+ rescue StandardError => e
40
+ Legion::Logging.error "GraphQL execution error: #{e.message}" if defined?(Legion::Logging)
41
+ status 500
42
+ Legion::JSON.dump({ errors: [{ message: e.message }] })
43
+ end
44
+
45
+ app.get '/api/graphql' do
46
+ content_type 'text/html'
47
+ Legion::API::Routes::GraphQL.graphiql_html
48
+ end
49
+ end
50
+
51
+ def self.graphiql_html
52
+ <<~HTML
53
+ <!DOCTYPE html>
54
+ <html>
55
+ <head>
56
+ <title>LegionIO GraphiQL</title>
57
+ <link href="https://cdn.jsdelivr.net/npm/graphiql@3/graphiql.min.css" rel="stylesheet" />
58
+ </head>
59
+ <body style="margin:0">
60
+ <div id="graphiql" style="height:100vh"></div>
61
+ <script crossorigin src="https://cdn.jsdelivr.net/npm/react@18/umd/react.development.js"></script>
62
+ <script crossorigin src="https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.development.js"></script>
63
+ <script crossorigin src="https://cdn.jsdelivr.net/npm/graphiql@3/graphiql.min.js"></script>
64
+ <script>
65
+ const root = ReactDOM.createRoot(document.getElementById('graphiql'));
66
+ root.render(React.createElement(GraphiQL, {
67
+ fetcher: GraphiQL.createFetcher({ url: '/api/graphql' })
68
+ }));
69
+ </script>
70
+ </body>
71
+ </html>
72
+ HTML
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -8,6 +8,7 @@ module Legion
8
8
  register_collection(app)
9
9
  register_member(app)
10
10
  register_sub_resources(app)
11
+ register_approvals(app)
11
12
  register_teams(app)
12
13
  end
13
14
 
@@ -212,6 +213,52 @@ module Legion
212
213
  end
213
214
  end
214
215
 
216
+ def self.register_approvals(app)
217
+ require 'legion/digital_worker/registration'
218
+
219
+ app.get '/api/workers/approvals' do
220
+ require_data!
221
+ workers = Legion::DigitalWorker::Registration.pending_approvals
222
+ json_response({ data: workers.map(&:values), count: workers.size })
223
+ end
224
+
225
+ app.post '/api/workers/:id/approve' do
226
+ require_data!
227
+ body = parse_request_body
228
+ approver = body[:approver] || current_owner_msid || 'api'
229
+ notes = body[:notes]
230
+
231
+ worker = Legion::DigitalWorker::Registration.approve(params[:id], approver: approver, notes: notes)
232
+ json_response(worker.values)
233
+ rescue ArgumentError => e
234
+ json_error('invalid_request', e.message, status_code: 422)
235
+ rescue Legion::DigitalWorker::Lifecycle::InvalidTransition => e
236
+ json_error('invalid_transition', e.message, status_code: 422)
237
+ rescue StandardError => e
238
+ Legion::Logging.error "API worker approve error: #{e.message}"
239
+ json_error('approve_error', e.message, status_code: 500)
240
+ end
241
+
242
+ app.post '/api/workers/:id/reject' do
243
+ require_data!
244
+ body = parse_request_body
245
+ approver = body[:approver] || current_owner_msid || 'api'
246
+ reason = body[:reason]
247
+
248
+ halt 422, json_error('missing_field', 'reason is required', status_code: 422) unless reason
249
+
250
+ worker = Legion::DigitalWorker::Registration.reject(params[:id], approver: approver, reason: reason)
251
+ json_response(worker.values)
252
+ rescue ArgumentError => e
253
+ json_error('invalid_request', e.message, status_code: 422)
254
+ rescue Legion::DigitalWorker::Lifecycle::InvalidTransition => e
255
+ json_error('invalid_transition', e.message, status_code: 422)
256
+ rescue StandardError => e
257
+ Legion::Logging.error "API worker reject error: #{e.message}"
258
+ json_error('reject_error', e.message, status_code: 500)
259
+ end
260
+ end
261
+
215
262
  def self.register_teams(app)
216
263
  app.get '/api/teams/:team/workers' do
217
264
  require_data!
@@ -232,7 +279,7 @@ module Legion
232
279
  end
233
280
 
234
281
  class << self
235
- private :register_collection, :register_member, :register_sub_resources, :register_teams
282
+ private :register_collection, :register_member, :register_sub_resources, :register_approvals, :register_teams
236
283
  end
237
284
  end
238
285
  end