openc3 7.0.0.pre.rc2 → 7.0.0.pre.rc3

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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +13 -4
  3. data/bin/pipinstall +6 -7
  4. data/bin/pipuninstall +3 -5
  5. data/data/config/widgets.yaml +10 -0
  6. data/lib/openc3/api/cmd_api.rb +2 -0
  7. data/lib/openc3/api/settings_api.rb +2 -0
  8. data/lib/openc3/microservices/queue_microservice.rb +6 -2
  9. data/lib/openc3/migrations/20241208080000_no_critical_cmd.rb +1 -1
  10. data/lib/openc3/migrations/20250402000000_periodic_only_default.rb +1 -1
  11. data/lib/openc3/migrations/20251022000000_remove_unique_id.rb +1 -1
  12. data/lib/openc3/migrations/20260203000000_remove_store_id.rb +28 -0
  13. data/lib/openc3/migrations/20260204000000_remove_decom_reducer.rb +27 -1
  14. data/lib/openc3/models/activity_model.rb +26 -6
  15. data/lib/openc3/models/auth_model.rb +54 -19
  16. data/lib/openc3/models/cvt_model.rb +79 -97
  17. data/lib/openc3/models/model.rb +16 -0
  18. data/lib/openc3/models/plugin_model.rb +18 -12
  19. data/lib/openc3/models/python_package_model.rb +2 -2
  20. data/lib/openc3/models/queue_model.rb +5 -3
  21. data/lib/openc3/models/target_model.rb +43 -8
  22. data/lib/openc3/models/tool_config_model.rb +12 -0
  23. data/lib/openc3/models/widget_model.rb +1 -7
  24. data/lib/openc3/script/web_socket_api.rb +1 -1
  25. data/lib/openc3/topics/command_topic.rb +1 -0
  26. data/lib/openc3/utilities/authentication.rb +46 -7
  27. data/lib/openc3/utilities/authorization.rb +8 -1
  28. data/lib/openc3/utilities/aws_bucket.rb +2 -3
  29. data/lib/openc3/utilities/questdb_client.rb +46 -0
  30. data/lib/openc3/version.rb +5 -5
  31. data/templates/tool_angular/package.json +1 -1
  32. data/templates/tool_vue/package.json +1 -1
  33. data/templates/widget/package.json +1 -1
  34. metadata +4 -18
  35. data/lib/openc3/migrations/20251213120000_reinstall_plugins.rb +0 -45
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a7c545191fe3248b560961c422b5906474a068d06df3d480a35a61dba621e469
4
- data.tar.gz: 77b9201a38591fad76787947918fd6910a8599e09368cdfdf9d0870520a9f073
3
+ metadata.gz: 920a681652723ab469d3311e6f28332ea202068300c3a506d3314c552f5c0f30
4
+ data.tar.gz: 2f35277fa1e25eb30646a27e8f45365cb2fd39d7d3b24758baabf004e9fe54b5
5
5
  SHA512:
6
- metadata.gz: 164c14d7fd49d30bcaf8f8838cd2e3d63c3fbbccb514e7b5a40348868e68317100d8a48beda0a3015959d0f49d3093b40b1184e517093ae7a4df4aafb8710be0
7
- data.tar.gz: cb6276f37e41717565abbbb24d7df1006cf4dfbfdeff92c76b34638dff29279dcfed536aced5a488d1aacf63541568d3a0c8da4b7edd5ecaed9f792b3a12a7ae
6
+ metadata.gz: 1cd5cb567a743e90742e76db3b4ab8498f34d8b6ba6dc0032e351bb6e3fdfbc4f8db826cc907d500f4670ef461bd49193be74e8264648b6a0f5908d090b7e6cf
7
+ data.tar.gz: 9af5921a88413af21113b00c0f99dcc023e70c21b4dfc9e7f0f8840fbe226765f86d80e82a7b874458468c45cea590a3e6b0ab2d702d29613818e19e48b473be
data/bin/openc3cli CHANGED
@@ -1348,12 +1348,21 @@ if not ARGV[0].nil? # argument(s) given
1348
1348
  exit 0
1349
1349
  end
1350
1350
  client = OpenC3::Bucket.getClient()
1351
- ENV.map do |key, value|
1352
- if key.match(/^OPENC3_(.+)_BUCKET$/) && !value.empty?
1353
- client.create(value)
1351
+ if ENV.fetch('OPENC3_CLOUD', 'local') == 'local'
1352
+ # In local mode we want to create all buckets to ensure they exist
1353
+ # Cloud deployments will have created buckets during provisioning
1354
+ # so we only need to ensure the correct policies and permissions are in place
1355
+ ENV.map do |key, value|
1356
+ if key.match(/^OPENC3_(.+)_BUCKET$/) && !value.empty?
1357
+ client.create(value)
1358
+ end
1354
1359
  end
1355
1360
  end
1356
- client.ensure_public(ENV['OPENC3_TOOLS_BUCKET'])
1361
+ # Unless explicitly disabled, ensure the tools bucket is public
1362
+ unless ENV.fetch("OPENC3_NO_BUCKET_POLICY", false)
1363
+ client.ensure_public(ENV['OPENC3_TOOLS_BUCKET'])
1364
+ end
1365
+ # Always ensure the scriptrunner policy is in place since it is required for script execution
1357
1366
  client.ensure_scriptrunner_policy(ENV['OPENC3_CONFIG_BUCKET'], ENV['OPENC3_LOGS_BUCKET'])
1358
1367
 
1359
1368
  when 'runmigrations'
data/bin/pipinstall CHANGED
@@ -1,14 +1,13 @@
1
1
  #!/bin/sh
2
- python3 -m venv $PYTHONUSERBASE
3
- source $PYTHONUSERBASE/bin/activate
4
- echo "pip3 install $@"
5
- pip3 install "$@"
2
+ uv venv "$PYTHONUSERBASE"
3
+ echo "uv pip install $@"
4
+ uv pip install --python "$PYTHONUSERBASE" "$@"
6
5
  if [ $? -eq 0 ]; then
7
6
  echo "Command succeeded"
8
7
  else
9
8
  echo "Command failed - retrying with --no-index"
10
- pip3 install --no-index "$@"
11
- if [ $? -eq 0 ]; then
12
- echo "ERROR: pip3 install failed"
9
+ uv pip install --python "$PYTHONUSERBASE" --no-index "$@"
10
+ if [ $? -ne 0 ]; then
11
+ echo "ERROR: uv pip install failed"
13
12
  fi
14
13
  fi
data/bin/pipuninstall CHANGED
@@ -1,10 +1,8 @@
1
1
  #!/bin/sh
2
- python3 -m venv $PYTHONUSERBASE
3
- source $PYTHONUSERBASE/bin/activate
4
- echo "pip3 uninstall $@"
5
- pip3 uninstall "$@"
2
+ echo "uv pip uninstall $@"
3
+ uv pip uninstall --python "$PYTHONUSERBASE" "$@"
6
4
  if [ $? -eq 0 ]; then
7
5
  echo "Command succeeded"
8
6
  else
9
- echo "ERROR: pip3 uninstall failed"
7
+ echo "ERROR: uv pip uninstall failed"
10
8
  fi
@@ -894,6 +894,16 @@ Telemetry Widgets:
894
894
  END
895
895
  LIMITSCOLOR INST HEALTH_STATUS TEMP2 # Default is label with just item name
896
896
  LIMITSCOLOR INST HEALTH_STATUS TEMP3 CONVERTED 20 TRUE # Full TGT/PKT/ITEM label
897
+ LIMITSCOLOR INST HEALTH_STATUS TEMP4
898
+ SETTING ASTRO TRUE
899
+ settings:
900
+ ASTRO:
901
+ summary: Display Astro status icons instead of a colored circle
902
+ description:
903
+ When set, the LIMITSCOLOR renders an Astro (rux-status) icon whose shape reflects
904
+ the severity level, improving accessibility for colorblind users.
905
+ Limits colors are automatically mapped to Astro statuses
906
+ (GREEN to normal, RED to critical, YELLOW to caution, BLUE to standby).
897
907
  VALUELIMITSBAR:
898
908
  summary: Displays an item VALUE followed by LIMITSBAR
899
909
  parameters:
@@ -545,6 +545,8 @@ module OpenC3
545
545
  target_name: target_name,
546
546
  cmd_name: cmd_name,
547
547
  cmd_params: cmd_params,
548
+ validate: validate,
549
+ timeout: timeout,
548
550
  username: username,
549
551
  scope: scope)
550
552
  else
@@ -73,6 +73,8 @@ module OpenC3
73
73
  authorize(permission: 'admin', manual: manual, scope: scope, token: token)
74
74
  SettingModel.set({ name: name, data: data }, scope: scope)
75
75
  LocalMode.save_setting(scope, name, data)
76
+ username = user_info(token)['username'] || 'Anonymous'
77
+ Logger.info("User #{username} saved setting '#{name}': #{data}", scope: scope, user: username)
76
78
  end
77
79
  # save_setting is DEPRECATED
78
80
  alias save_setting set_setting
@@ -71,10 +71,14 @@ module OpenC3
71
71
  else
72
72
  cmd_params = {}
73
73
  end
74
- cmd(command['target_name'], command['cmd_name'], cmd_params, queue: false, scope: @scope)
74
+ validate = command.key?('validate') ? command['validate'] : true
75
+ timeout = command['timeout']
76
+ cmd(command['target_name'], command['cmd_name'], cmd_params, queue: false, validate: validate, timeout: timeout, scope: @scope)
75
77
  elsif command['value']
76
78
  # Legacy format: use single string parameter for backwards compatibility
77
- cmd(command['value'], queue: false, scope: @scope)
79
+ validate = command.key?('validate') ? command['validate'] : true
80
+ timeout = command['timeout']
81
+ cmd(command['value'], queue: false, validate: validate, timeout: timeout, scope: @scope)
78
82
  else
79
83
  @logger.error "QueueProcessor: Invalid command format, missing required fields"
80
84
  end
@@ -13,7 +13,7 @@ module OpenC3
13
13
  end
14
14
 
15
15
  def self.run
16
- ScopeModel.get_all_models(scope: nil).each do |scope, scope_model|
16
+ ScopeModel.get_all_models(scope: nil).each do |scope, _scope_model|
17
17
  model = MicroserviceModel.get_model(name: "#{scope}__CRITICALCMD__#{scope}", scope: scope)
18
18
  if BASE # Only remove the critical command model if we're not enterprise
19
19
  model.destroy if model
@@ -5,7 +5,7 @@ require 'openc3/models/microservice_model'
5
5
  module OpenC3
6
6
  class PeriodicOnlyDefault < Migration
7
7
  def self.run
8
- ScopeModel.get_all_models(scope: nil).each do |scope, scope_model|
8
+ ScopeModel.get_all_models(scope: nil).each do |scope, _scope_model|
9
9
  next if scope == 'DEFAULT'
10
10
  model = MicroserviceModel.get_model(name: "#{scope}__SCOPEMULTI__#{scope}", scope: scope)
11
11
  if model
@@ -5,7 +5,7 @@ require 'openc3/models/microservice_model'
5
5
  module OpenC3
6
6
  class RemoveUniqueId < Migration
7
7
  def self.run
8
- ScopeModel.get_all_models(scope: nil).each do |scope, scope_model|
8
+ ScopeModel.get_all_models(scope: nil).each do |scope, _scope_model|
9
9
  target_models = TargetModel.all(scope: scope)
10
10
  target_models.each do |name, target_model|
11
11
  target_model.delete("cmd_unique_id_mode")
@@ -0,0 +1,28 @@
1
+ require 'openc3/utilities/migration'
2
+ require 'openc3/models/scope_model'
3
+ require 'openc3/models/plugin_model'
4
+
5
+ module OpenC3
6
+ # Removes the store_id property from plugin models. It got renamed to
7
+ # store_plugin_id in PR #2858 but that also depends on having
8
+ # store_version_id, which didn't exist prior to this version and can't
9
+ # be determined without some introspection on the plugin and querying
10
+ # the app store (online). When store_plugin_id is unset, COSMOS treats
11
+ # the plugin like it was installed from a gem file.
12
+ class RemoveStoreId < Migration
13
+ def self.run
14
+ ScopeModel.get_all_models(scope: nil).each do |scope, _scope_model|
15
+ plugin_models = PluginModel.all(scope: scope)
16
+ plugin_models.each do |_name, plugin_model|
17
+ plugin_model.delete("store_id")
18
+ model = PluginModel.from_json(plugin_model, scope: scope)
19
+ model.update()
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ unless ENV['OPENC3_NO_MIGRATE']
27
+ OpenC3::RemoveStoreId.run
28
+ end
@@ -10,14 +10,16 @@
10
10
  # if purchased from OpenC3, Inc.
11
11
 
12
12
  require 'openc3/utilities/migration'
13
+ require 'openc3/utilities/bucket'
13
14
  require 'openc3/models/scope_model'
14
15
  require 'openc3/models/target_model'
15
16
  require 'openc3/models/microservice_model'
17
+ require 'openc3/models/plugin_model'
16
18
 
17
19
  module OpenC3
18
20
  class RemoveDecomLogSettings < Migration
19
21
  def self.run
20
- ScopeModel.get_all_models(scope: nil).each do |scope, scope_model|
22
+ ScopeModel.get_all_models(scope: nil).each do |scope, _scope_model|
21
23
  target_models = TargetModel.all(scope: scope)
22
24
  target_models.each do |name, target_model|
23
25
  # Remove deprecated decom log settings from target model
@@ -51,6 +53,30 @@ module OpenC3
51
53
  end
52
54
  end
53
55
  end
56
+
57
+ # Reinstall all plugins to regenerate microservice configs with correct settings
58
+ # This must happen after removing deprecated keys above
59
+ client = Bucket.getClient()
60
+ unless client.exist?(ENV['OPENC3_CONFIG_BUCKET']) && client.exist?(ENV['OPENC3_TOOLS_BUCKET'])
61
+ Logger.info("Skipping plugin reinstall - buckets do not exist yet (fresh install or new storage backend)")
62
+ return
63
+ end
64
+
65
+ ScopeModel.get_all_models(scope: nil).each do |scope, _scope_model|
66
+ plugins = PluginModel.all(scope: scope)
67
+ plugins.each do |plugin_name, plugin_data|
68
+ begin
69
+ Logger.info("Reinstalling plugin #{plugin_name} in scope #{scope}")
70
+ plugin_model = PluginModel.from_json(plugin_data, scope: scope)
71
+ plugin_model.undeploy
72
+ plugin_model.restore
73
+ Logger.info("Successfully reinstalled plugin #{plugin_name} in scope #{scope}")
74
+ rescue Exception => e
75
+ Logger.error("Error reinstalling plugin #{plugin_name} in scope #{scope}: #{e.formatted}")
76
+ # Continue with other plugins even if one fails
77
+ end
78
+ end
79
+ end
54
80
  end
55
81
  end
56
82
  end
@@ -261,7 +261,7 @@ module OpenC3
261
261
 
262
262
  # Update the Redis hash at primary_key and set the score equal to the start Epoch time
263
263
  # the member is set to the JSON generated via calling as_json
264
- def create(overlap: true)
264
+ def create(overlap: true, username: nil)
265
265
  if @recurring['end'] and @recurring['frequency'] and @recurring['span']
266
266
  # First validate the initial recurring activity ... all others are just offsets
267
267
  validate_input(start: @start, stop: @stop, kind: @kind, data: @data)
@@ -290,7 +290,7 @@ module OpenC3
290
290
 
291
291
  # Update @updated_at and add an event assuming it all completes ok
292
292
  @updated_at = Time.now.to_nsec_from_epoch
293
- add_event(status: 'created')
293
+ add_event(status: 'created', username: username)
294
294
 
295
295
  Store.multi do |multi|
296
296
  (@start..@recurring['end']).step(recurrence).each do |start_time|
@@ -326,7 +326,7 @@ module OpenC3
326
326
  end
327
327
  end
328
328
  @updated_at = Time.now.to_nsec_from_epoch
329
- add_event(status: 'created')
329
+ add_event(status: 'created', username: username)
330
330
  Store.zadd(@primary_key, @start, JSON.generate(self.as_json, allow_nan: true))
331
331
  notify(kind: 'created')
332
332
  end
@@ -335,7 +335,7 @@ module OpenC3
335
335
  # Update the Redis hash at primary_key and remove the current activity at the current score
336
336
  # and update the score to the new score equal to the start Epoch time this uses a multi
337
337
  # to execute both the remove and create.
338
- def update(start:, stop:, kind:, data:, overlap: true)
338
+ def update(start:, stop:, kind:, data:, overlap: true, username: nil)
339
339
  array = Store.zrangebyscore(@primary_key, @start, @start)
340
340
  if array.length == 0
341
341
  raise ActivityError.new "failed to find activity at: #{@start}"
@@ -350,10 +350,22 @@ module OpenC3
350
350
  raise ActivityOverlapError.new "failed to update #{old_start}, no activities can overlap, collision: #{collision}"
351
351
  end
352
352
  end
353
+
354
+ # Compute changeset for audit trail before applying changes
355
+ changes = {}
356
+ diff_field(changes, 'start', @start, start)
357
+ diff_field(changes, 'stop', @stop, stop)
358
+ diff_field(changes, 'kind', @kind, kind)
359
+ old_data = @data.reject { |k, _| k == 'username' }
360
+ new_data = data.reject { |k, _| k == 'username' }
361
+ (old_data.keys | new_data.keys).each do |key|
362
+ diff_field(changes, "data.#{key}", old_data[key], new_data[key])
363
+ end
364
+
353
365
  set_input(start: start, stop: stop, kind: kind, data: data, events: @events)
354
366
  @updated_at = Time.now.to_nsec_from_epoch
355
367
 
356
- add_event(status: 'updated')
368
+ add_event(status: 'updated', username: username, changes: changes.empty? ? nil : changes)
357
369
  json = Store.zrangebyscore(@primary_key, old_start, old_start)
358
370
  parsed = json.map { |value| JSON.parse(value, allow_nan: true, create_additions: true) }
359
371
  parsed.each_with_index do |value, index|
@@ -397,14 +409,22 @@ module OpenC3
397
409
 
398
410
  # add_event will make an event. This will NOT save the object to the redis database
399
411
  # @param [String] status - the event status such as "queued" or "updated" or "created"
400
- def add_event(status:)
412
+ # @param [String] username - optional username of who performed the action
413
+ # @param [Hash] changes - optional hash describing what fields changed (for audit)
414
+ def add_event(status:, username: nil, changes: nil)
401
415
  event = {
402
416
  'time' => Time.now.to_i,
403
417
  'event' => status
404
418
  }
419
+ event['username'] = username if username
420
+ event['changes'] = changes if changes
405
421
  @events << event
406
422
  end
407
423
 
424
+ def diff_field(changes, field, old_val, new_val)
425
+ changes[field] = {'old' => old_val, 'new' => new_val} unless old_val == new_val
426
+ end
427
+
408
428
  # update the redis stream / timeline topic that something has changed
409
429
  def notify(kind:, extra: nil)
410
430
  notification = {
@@ -41,6 +41,9 @@ module OpenC3
41
41
 
42
42
  MIN_PASSWORD_LENGTH = 8
43
43
 
44
+ SESSION_PREFIX = "ses_"
45
+ OTP_PREFIX = "otp_"
46
+
44
47
  def self.set?(key = PRIMARY_KEY)
45
48
  Store.exists(key) == 1
46
49
  end
@@ -58,36 +61,49 @@ module OpenC3
58
61
 
59
62
  return false if service_only
60
63
 
61
- return verify_no_service(token, no_password: no_password)
64
+ mode = no_password ? :token : :any
65
+ return verify_no_service(token, mode: mode)
62
66
  end
63
67
 
64
68
  # Checks whether the provided token is a valid user password or session token.
65
69
  # @param token [String] the plaintext password or session token to check (required)
66
- # @param no_password [Boolean] enforces use of a session token (default: true)
70
+ # @param mode [String] optionally restrict verification to just the password or token. Valid values: :password, :token, or :any (default :token)
67
71
  # @return [Boolean] whether the provided password/token is valid
68
- def self.verify_no_service(token, no_password: true)
72
+ def self.verify_no_service(token, mode: :token)
73
+ modes = [:password, :token, :any]
74
+ raise ArgumentError, "Invalid mode '#{mode}': must be one of #{modes}" unless modes.include?(mode)
75
+
69
76
  return false if token.nil? or token.empty?
70
77
 
71
78
  # Check cached session tokens and password hash
72
79
  time = Time.now
73
- return true if @@session_cache and (time - @@session_cache_time) < SESSION_CACHE_TIMEOUT and @@session_cache[token]
74
- unless no_password
75
- return true if @@pw_hash_cache and (time - @@pw_hash_cache_time) < PW_HASH_CACHE_TIMEOUT and Argon2::Password.verify_password(token, @@pw_hash_cache)
80
+ unless mode == :password
81
+ if @@session_cache and (time - @@session_cache_time) < SESSION_CACHE_TIMEOUT and @@session_cache[token]
82
+ terminate_otp(token)
83
+ return true
84
+ end
85
+
86
+ # Check stored session tokens
87
+ @@session_cache = Store.hgetall(SESSIONS_KEY)
88
+ @@session_cache_time = time
89
+ if @@session_cache[token]
90
+ terminate_otp(token)
91
+ return true
92
+ end
76
93
  end
77
94
 
78
- # Check stored session tokens
79
- @@session_cache = Store.hgetall(SESSIONS_KEY)
80
- @@session_cache_time = time
81
- return true if @@session_cache[token]
95
+ unless mode == :token
96
+ return true if @@pw_hash_cache and (time - @@pw_hash_cache_time) < PW_HASH_CACHE_TIMEOUT and Argon2::Password.verify_password(token, @@pw_hash_cache)
82
97
 
83
- return false if no_password
98
+ # Check stored password hash
99
+ pw_hash = Store.get(PRIMARY_KEY)
100
+ raise "invalid password hash" if pw_hash.nil? || !pw_hash.start_with?("$argon2") # Catch users who didn't run the migration utility when upgrading to COSMOS 7
101
+ @@pw_hash_cache = pw_hash
102
+ @@pw_hash_cache_time = time
103
+ return true if Argon2::Password.verify_password(token, @@pw_hash_cache)
104
+ end
84
105
 
85
- # Check stored password hash
86
- pw_hash = Store.get(PRIMARY_KEY)
87
- raise "invalid password hash" unless pw_hash.start_with?("$argon2") # Catch users who didn't run the migration utility when upgrading to COSMOS 7
88
- @@pw_hash_cache = pw_hash
89
- @@pw_hash_cache_time = time
90
- return Argon2::Password.verify_password(token, @@pw_hash_cache)
106
+ return false
91
107
  end
92
108
 
93
109
  def self.set(password, old_password, key = PRIMARY_KEY)
@@ -96,17 +112,25 @@ module OpenC3
96
112
 
97
113
  if set?(key)
98
114
  raise "old_password must not be nil or empty" if old_password.nil? or old_password.empty?
99
- raise "old_password incorrect" unless verify_no_service(old_password, no_password: false)
115
+ raise "old_password incorrect" unless verify_no_service(old_password, mode: :password)
100
116
  end
101
117
  pw_hash = Argon2::Password.create(password, profile: ARGON2_PROFILE)
102
118
  Store.set(key, pw_hash)
103
119
  @@pw_hash_cache = nil
104
120
  @@pw_hash_cache_time = nil
121
+ logout
105
122
  end
106
123
 
107
124
  # Creates a new session token. DO NOT CALL BEFORE VERIFYING.
108
- def self.generate_session
125
+ # @param otp [Boolean] whether to create a one-time use token (default: false)
126
+ # @return [String] the new session token
127
+ def self.generate_session(otp: false)
109
128
  token = SecureRandom.urlsafe_base64(nil, false)
129
+ if otp
130
+ token = OTP_PREFIX + token
131
+ else
132
+ token = SESSION_PREFIX + token
133
+ end
110
134
  Store.hset(SESSIONS_KEY, token, Time.now.iso8601)
111
135
  return token
112
136
  end
@@ -117,5 +141,16 @@ module OpenC3
117
141
  @@session_cache = nil
118
142
  @@session_cache_time = nil
119
143
  end
144
+
145
+ # Terminates the given session token.
146
+ def self.terminate(token)
147
+ Store.hdel(SESSIONS_KEY, token)
148
+ @@session_cache.delete(token) if @@session_cache
149
+ end
150
+
151
+ # Terminates the session if the token is an OTP.
152
+ def self.terminate_otp(token)
153
+ terminate(token) if token.start_with?(OTP_PREFIX)
154
+ end
120
155
  end
121
156
  end