diff --git a/.circleci/config.yml b/.circleci/config.yml
index 6d10a68..63169a2 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -18,7 +18,7 @@ workflows:
code-climate-report: false
matrix:
parameters:
- ruby-version: ["3.2.5", "3.3.6", "3.4.2"]
+ ruby-version: ["3.2.5", "3.3.6", "3.4.8", "4.0.0", "4.0.1"]
rails-version: ["~> 7", "~> 8"]
alias: required-matrix-tests
name: test-ruby<< matrix.ruby-version >>-rails<< matrix.rails-version >>
diff --git a/.cursorrules b/.cursorrules
new file mode 100644
index 0000000..53aa163
--- /dev/null
+++ b/.cursorrules
@@ -0,0 +1,183 @@
+# CommandTower Schema Structure Rules
+
+## Schema Organization Requirements
+
+### Every Endpoint Must Have Request and Response Schemas
+
+**MANDATORY**: Every API endpoint MUST have both a Request schema and a Response schema. No exceptions.
+
+### Directory Structure
+
+All schemas are organized in `lib/command_tower/schema/` with the following structure:
+
+```
+lib/command_tower/schema/
+├── shared/ # Reusable schemas (cross-domain + domain-specific)
+│ ├── user.rb # Cross-domain: User entity
+│ ├── page.rb # Cross-domain: Page for pagination
+│ ├── pagination.rb # Cross-domain: Pagination structure
+│ ├── inbox/ # Domain-specific shared: Inbox reusable structures
+│ │ ├── metadata.rb
+│ │ ├── message_blast_metadata.rb
+│ │ └── modified.rb
+│ └── admin/ # Domain-specific shared: Admin reusable structures
+│ └── users.rb
+│
+├── entities/ # Domain-specific entity schemas (referenced by responses)
+│ └── inbox/
+│ ├── message_entity.rb
+│ └── message_blast_entity.rb
+│
+├── error/ # Error schemas (can be referenced)
+│ ├── base.rb
+│ ├── invalid_argument.rb
+│ └── invalid_argument_response.rb
+│
+└── [endpoint folders]/ # Request/Response schemas organized by controller/action
+ ├── auth/
+ │ └── plain_text/
+ │ ├── login/
+ │ │ ├── request.rb
+ │ │ └── response.rb
+ │ └── ...
+ ├── user/
+ │ ├── show/
+ │ │ ├── request.rb
+ │ │ └── response.rb
+ │ └── modify/
+ │ ├── request.rb
+ │ └── response.rb
+ ├── admin/
+ │ ├── show/
+ │ ├── modify/
+ │ └── modify_role/
+ └── inbox/
+ ├── messages/
+ │ ├── metadata/
+ │ ├── message/
+ │ ├── ack/
+ │ └── ...
+ └── blast/
+ ├── metadata/
+ ├── create/
+ ├── show/
+ ├── modify/
+ └── delete/
+```
+
+### Schema Organization Rules
+
+1. **Request/Response Schemas**:
+ - MUST be placed in endpoint-specific folders: `{controller}/{action}/request.rb` and `{controller}/{action}/response.rb`
+ - Example: `auth/plain_text/login/request.rb` and `auth/plain_text/login/response.rb`
+ - CAN reference schemas from `shared/`, `entities/`, and `error/`
+ - CANNOT reference other Request/Response schemas
+
+2. **Shared Schemas** (`shared/`):
+ - Cross-domain reusable schemas go at root of `shared/` (e.g., `shared/user.rb`, `shared/page.rb`)
+ - Domain-specific reusable structures go in domain subfolders (e.g., `shared/inbox/metadata.rb`)
+ - Can be referenced by any Request/Response schema
+
+3. **Entity Schemas** (`entities/`):
+ - Domain-specific entity schemas that represent data models
+ - Organized by domain (e.g., `entities/inbox/message_entity.rb`)
+ - Can be referenced by Request/Response schemas
+
+4. **Error Schemas** (`error/`):
+ - Standard error response structures
+ - Can be referenced by Request/Response schemas
+
+### Naming Conventions
+
+- **Request schemas**: `{Controller}::{Action}::Request`
+ - Example: `CommandTower::Schema::Auth::PlainText::Login::Request`
+
+- **Response schemas**: `{Controller}::{Action}::Response`
+ - Example: `CommandTower::Schema::Auth::PlainText::Login::Response`
+
+- **Shared schemas**: `CommandTower::Schema::Shared::{Name}`
+ - Example: `CommandTower::Schema::Shared::User`
+ - Domain-specific: `CommandTower::Schema::Shared::Inbox::Metadata`
+
+- **Entity schemas**: `CommandTower::Schema::Entities::{Domain}::{Name}`
+ - Example: `CommandTower::Schema::Entities::Inbox::MessageEntity`
+
+### Implementation Requirements
+
+1. **When creating a new endpoint**:
+ - Create both `request.rb` and `response.rb` files in the appropriate endpoint folder
+ - Update `lib/command_tower/schema.rb` to require both files
+ - Use the Request schema for error validation in controllers
+ - Use the Response schema for successful responses
+
+2. **Request Schema Guidelines**:
+ - Define all fields that the endpoint accepts
+ - Mark optional fields with `required: false`
+ - Use appropriate types (`String`, `Integer`, `JsonSchematize::Boolean`, etc.)
+ - For GET endpoints, Request schemas can be empty but must still exist
+
+3. **Response Schema Guidelines**:
+ - Reference shared schemas or entity schemas when possible
+ - If the response is just a shared schema, document that in comments
+ - Define endpoint-specific fields if needed
+
+4. **Controller Usage**:
+ - Use Request schemas in `invalid_arguments!` calls for error validation
+ - Use Response schemas (or shared schemas directly) for successful responses via `schema_succesful!`
+
+### Examples
+
+**Creating a new endpoint schema:**
+```ruby
+# lib/command_tower/schema/user/profile/request.rb
+module CommandTower
+ module Schema
+ module User
+ module Profile
+ class Request < JsonSchematize::Generator
+ add_field name: :bio, type: String, required: false
+ end
+ end
+ end
+ end
+end
+
+# lib/command_tower/schema/user/profile/response.rb
+require "command_tower/schema/shared/user"
+
+module CommandTower
+ module Schema
+ module User
+ module Profile
+ class Response < JsonSchematize::Generator
+ # Response uses Shared::User directly in controller
+ end
+ end
+ end
+ end
+end
+```
+
+**Using in controller:**
+```ruby
+def profile
+ result = SomeService.(**params)
+ if result.success?
+ schema = CommandTower::Schema::Shared::User.convert_user_object(user: current_user)
+ schema_succesful!(status: 200, schema:)
+ else
+ invalid_arguments!(
+ status: 400,
+ message: result.msg,
+ argument_object: result.invalid_argument_hash,
+ schema: CommandTower::Schema::User::Profile::Request
+ )
+ end
+end
+```
+
+### Migration Notes
+
+- All existing endpoints have been migrated to this structure
+- Old schema files in `plain_text/`, `inbox/`, etc. have been moved or removed
+- All references have been updated to use the new namespace structure
diff --git a/Dockerfile b/Dockerfile
index 29c2696..9747e16 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# ./Dockerfile
-FROM ruby:3.3.6 as base
+FROM ruby:4.0.1 as base
# set some default ENV values for the image
ENV RAILS_LOG_TO_STDOUT 1
@@ -22,10 +22,9 @@ RUN apt-get install -y --no-install-recommends \
redis-tools
# install bundler
-ARG BUNDLER_VERSION=2.5.3
+ARG BUNDLER_VERSION=4.0.4
RUN gem install bundler -v "${BUNDLER_VERSION}"
RUN gem install annotate
RUN bundle config set force_ruby_platform true
COPY . $APP_HOME
-
diff --git a/Gemfile b/Gemfile
index 5df34f9..2313cd9 100644
--- a/Gemfile
+++ b/Gemfile
@@ -14,7 +14,7 @@ gem "pry"
# gem "json_schematize", path: "/local/json_schematize"
-gem "rails", ENV.fetch("BUNDLER_RAILS_VERSION", "~> 7")
+gem "rails", ENV.fetch("BUNDLER_RAILS_VERSION", "~> 8")
gem "rspec-rails"
gem "rspec_junit_formatter"
diff --git a/Gemfile.lock b/Gemfile.lock
index c648801..84be844 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
- command_tower (0.4.0)
+ command_tower (0.5.0)
bcrypt (>= 3)
class_composer (>= 2)
interactor
@@ -13,133 +13,141 @@ PATH
GEM
remote: https://rubygems.org/
specs:
- actioncable (7.2.2.1)
- actionpack (= 7.2.2.1)
- activesupport (= 7.2.2.1)
+ action_text-trix (2.1.16)
+ railties
+ actioncable (8.1.2)
+ actionpack (= 8.1.2)
+ activesupport (= 8.1.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
- actionmailbox (7.2.2.1)
- actionpack (= 7.2.2.1)
- activejob (= 7.2.2.1)
- activerecord (= 7.2.2.1)
- activestorage (= 7.2.2.1)
- activesupport (= 7.2.2.1)
+ actionmailbox (8.1.2)
+ actionpack (= 8.1.2)
+ activejob (= 8.1.2)
+ activerecord (= 8.1.2)
+ activestorage (= 8.1.2)
+ activesupport (= 8.1.2)
mail (>= 2.8.0)
- actionmailer (7.2.2.1)
- actionpack (= 7.2.2.1)
- actionview (= 7.2.2.1)
- activejob (= 7.2.2.1)
- activesupport (= 7.2.2.1)
+ actionmailer (8.1.2)
+ actionpack (= 8.1.2)
+ actionview (= 8.1.2)
+ activejob (= 8.1.2)
+ activesupport (= 8.1.2)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
- actionpack (7.2.2.1)
- actionview (= 7.2.2.1)
- activesupport (= 7.2.2.1)
+ actionpack (8.1.2)
+ actionview (= 8.1.2)
+ activesupport (= 8.1.2)
nokogiri (>= 1.8.5)
- racc
- rack (>= 2.2.4, < 3.2)
+ rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
- actiontext (7.2.2.1)
- actionpack (= 7.2.2.1)
- activerecord (= 7.2.2.1)
- activestorage (= 7.2.2.1)
- activesupport (= 7.2.2.1)
+ actiontext (8.1.2)
+ action_text-trix (~> 2.1.15)
+ actionpack (= 8.1.2)
+ activerecord (= 8.1.2)
+ activestorage (= 8.1.2)
+ activesupport (= 8.1.2)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
- actionview (7.2.2.1)
- activesupport (= 7.2.2.1)
+ actionview (8.1.2)
+ activesupport (= 8.1.2)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
- activejob (7.2.2.1)
- activesupport (= 7.2.2.1)
+ activejob (8.1.2)
+ activesupport (= 8.1.2)
globalid (>= 0.3.6)
- activemodel (7.2.2.1)
- activesupport (= 7.2.2.1)
- activerecord (7.2.2.1)
- activemodel (= 7.2.2.1)
- activesupport (= 7.2.2.1)
+ activemodel (8.1.2)
+ activesupport (= 8.1.2)
+ activerecord (8.1.2)
+ activemodel (= 8.1.2)
+ activesupport (= 8.1.2)
timeout (>= 0.4.0)
- activestorage (7.2.2.1)
- actionpack (= 7.2.2.1)
- activejob (= 7.2.2.1)
- activerecord (= 7.2.2.1)
- activesupport (= 7.2.2.1)
+ activestorage (8.1.2)
+ actionpack (= 8.1.2)
+ activejob (= 8.1.2)
+ activerecord (= 8.1.2)
+ activesupport (= 8.1.2)
marcel (~> 1.0)
- activesupport (7.2.2.1)
+ activesupport (8.1.2)
base64
- benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
+ json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
- annotate (3.2.0)
- activerecord (>= 3.2, < 8.0)
- rake (>= 10.4, < 14.0)
- base64 (0.2.0)
- bcrypt (3.1.20)
- benchmark (0.4.0)
- bigdecimal (3.1.9)
+ uri (>= 0.13.1)
+ annotate (2.6.5)
+ activerecord (>= 2.3.0)
+ rake (>= 0.8.7)
+ base64 (0.3.0)
+ bcrypt (3.1.21)
+ bigdecimal (4.0.1)
builder (3.3.0)
class_composer (2.1.0)
coderay (1.1.3)
- concurrent-ruby (1.3.5)
- connection_pool (2.5.0)
+ concurrent-ruby (1.3.6)
+ connection_pool (3.0.2)
crass (1.0.6)
- database_cleaner-active_record (2.2.0)
+ database_cleaner-active_record (2.2.2)
activerecord (>= 5.a)
- database_cleaner-core (~> 2.0.0)
+ database_cleaner-core (~> 2.0)
database_cleaner-core (2.0.1)
- date (3.4.1)
- diff-lcs (1.6.0)
+ date (3.5.1)
+ diff-lcs (1.6.2)
docile (1.4.1)
- drb (2.2.1)
+ drb (2.2.3)
+ erb (6.0.1)
erubi (1.13.1)
- factory_bot (6.5.1)
+ factory_bot (6.5.6)
activesupport (>= 6.1.0)
- faker (3.5.1)
+ faker (3.5.3)
i18n (>= 1.8.11, < 2)
- globalid (1.2.1)
+ globalid (1.3.0)
activesupport (>= 6.1)
- i18n (1.14.7)
+ i18n (1.14.8)
concurrent-ruby (~> 1.0)
- interactor (3.1.2)
- io-console (0.8.0)
- irb (1.15.1)
+ interactor (3.2.0)
+ ostruct
+ io-console (0.8.2)
+ irb (1.16.0)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
- json_schematize (0.12.0)
+ json (2.18.0)
+ json_schematize (0.13.1)
class_composer (>= 1.0)
- jwt (2.10.1)
+ jwt (3.1.2)
base64
- logger (1.6.6)
- loofah (2.24.0)
+ logger (1.7.0)
+ loofah (2.25.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
- mail (2.8.1)
+ mail (2.9.0)
+ logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
- marcel (1.0.4)
+ marcel (1.1.0)
method_source (1.1.0)
mini_mime (1.1.5)
- mini_portile2 (2.8.8)
- minitest (5.25.4)
- mysql2 (0.5.6)
- net-imap (0.5.6)
+ mini_portile2 (2.8.9)
+ minitest (6.0.1)
+ prism (~> 1.5)
+ mysql2 (0.5.7)
+ bigdecimal
+ net-imap (0.6.2)
date
net-protocol
net-pop (0.1.2)
@@ -148,89 +156,96 @@ GEM
timeout
net-smtp (0.5.1)
net-protocol
- nio4r (2.7.4)
- nokogiri (1.18.3)
+ nio4r (2.7.5)
+ nokogiri (1.19.0)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
null-logger (0.1.7)
- pp (0.6.2)
+ ostruct (0.6.3)
+ pp (0.6.3)
prettyprint
prettyprint (0.2.0)
- pry (0.15.2)
+ prism (1.8.0)
+ pry (0.16.0)
coderay (~> 1.1)
method_source (~> 1.0)
- psych (5.2.3)
+ reline (>= 0.6.0)
+ psych (5.3.1)
date
stringio
- puma (6.6.0)
+ puma (7.1.0)
nio4r (~> 2.0)
racc (1.8.1)
- rack (3.1.10)
- rack-cors (2.0.2)
- rack (>= 2.0.0)
- rack-session (2.1.0)
+ rack (3.2.4)
+ rack-cors (3.0.0)
+ logger
+ rack (>= 3.0.14)
+ rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
- rackup (2.2.1)
+ rackup (2.3.1)
rack (>= 3)
- rails (7.2.2.1)
- actioncable (= 7.2.2.1)
- actionmailbox (= 7.2.2.1)
- actionmailer (= 7.2.2.1)
- actionpack (= 7.2.2.1)
- actiontext (= 7.2.2.1)
- actionview (= 7.2.2.1)
- activejob (= 7.2.2.1)
- activemodel (= 7.2.2.1)
- activerecord (= 7.2.2.1)
- activestorage (= 7.2.2.1)
- activesupport (= 7.2.2.1)
+ rails (8.1.2)
+ actioncable (= 8.1.2)
+ actionmailbox (= 8.1.2)
+ actionmailer (= 8.1.2)
+ actionpack (= 8.1.2)
+ actiontext (= 8.1.2)
+ actionview (= 8.1.2)
+ activejob (= 8.1.2)
+ activemodel (= 8.1.2)
+ activerecord (= 8.1.2)
+ activestorage (= 8.1.2)
+ activesupport (= 8.1.2)
bundler (>= 1.15.0)
- railties (= 7.2.2.1)
+ railties (= 8.1.2)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
- rails-dom-testing (2.2.0)
+ rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
- railties (7.2.2.1)
- actionpack (= 7.2.2.1)
- activesupport (= 7.2.2.1)
+ railties (8.1.2)
+ actionpack (= 8.1.2)
+ activesupport (= 8.1.2)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
+ tsort (>= 0.2)
zeitwerk (~> 2.6)
- rake (13.2.1)
- rdoc (6.12.0)
+ rake (13.3.1)
+ rdoc (7.1.0)
+ erb
psych (>= 4.0.0)
- reline (0.6.0)
+ tsort
+ reline (0.6.3)
io-console (~> 0.5)
rotp (6.3.0)
- rspec-core (3.13.3)
+ rspec-core (3.13.6)
rspec-support (~> 3.13.0)
- rspec-expectations (3.13.3)
+ rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
- rspec-mocks (3.13.2)
+ rspec-mocks (3.13.7)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
- rspec-rails (7.1.1)
- actionpack (>= 7.0)
- activesupport (>= 7.0)
- railties (>= 7.0)
+ rspec-rails (8.0.2)
+ actionpack (>= 7.2)
+ activesupport (>= 7.2)
+ railties (>= 7.2)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
- rspec-support (3.13.2)
+ rspec-support (3.13.6)
rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0)
securerandom (0.4.1)
@@ -238,27 +253,30 @@ GEM
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
- simplecov-html (0.13.1)
+ simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
- sprockets (4.2.1)
+ sprockets (4.2.2)
concurrent-ruby (~> 1.0)
+ logger
rack (>= 2.2.4, < 4)
sprockets-rails (3.5.2)
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
- stringio (3.1.5)
- thor (1.3.2)
+ stringio (3.2.0)
+ thor (1.5.0)
timecop (0.9.10)
- timeout (0.4.3)
+ timeout (0.6.0)
+ tsort (0.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
+ uri (1.1.1)
useragent (0.16.11)
- websocket-driver (0.7.7)
+ websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
- zeitwerk (2.7.2)
+ zeitwerk (2.7.4)
PLATFORMS
ruby
@@ -274,7 +292,7 @@ DEPENDENCIES
pry
puma
rack-cors
- rails (~> 7)
+ rails (~> 8)
rails-controller-testing
rspec-rails
rspec_junit_formatter
@@ -283,4 +301,4 @@ DEPENDENCIES
timecop
BUNDLED WITH
- 2.5.22
+ 4.0.4
diff --git a/README.md b/README.md
index d24571a..9c30770 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,15 @@ Authentication ensures that we know which user is requesting the action. When th
For more info, check out [Authentication ReadMe](docs/authentication.md)
+**Web Applications**: For web apps using cookie-based authentication, see the [Cookie Authentication Guide](docs/cookie_authentication_guide.md) for complete setup instructions including CORS configuration.
+
+Cookie authentication provides secure, HttpOnly cookie support with automatic token management. Key security features include:
+- HttpOnly cookies prevent JavaScript access (XSS protection)
+- SameSite=Lax provides basic CSRF protection
+- Optional double-submit CSRF protection for additional security
+- Automatic cookie invalidation on authentication failures
+- Secure flag automatically enabled in production (HTTPS only)
+
## Authorization (RBAC)
Authorization is only done after authentication. This is the act of ensuring that the user can perform the action it is requesting. Put differently, I know who you are, but I need to validate you have permissions to complete the action. When the engine is unable to authorize the user, a `403` status code is returned.
diff --git a/app/controllers/command_tower/admin_controller.rb b/app/controllers/command_tower/admin_controller.rb
index 22efe99..43d0447 100644
--- a/app/controllers/command_tower/admin_controller.rb
+++ b/app/controllers/command_tower/admin_controller.rb
@@ -23,7 +23,7 @@ def show
def modify
result = CommandTower::UserAttributes::Modify.(user:, admin_user:, **modify_params)
if result.success?
- schema = CommandTower::Schema::User.convert_user_object(user: user.reload)
+ schema = CommandTower::Schema::Shared::User.convert_user_object(user: user.reload)
status = 201
schema_succesful!(status:, schema:)
else
@@ -32,7 +32,7 @@ def modify
status: 400,
message: result.msg,
argument_object: result.invalid_argument_hash,
- schema: CommandTower::Schema::PlainText::LoginRequest
+ schema: CommandTower::Schema::Admin::Modify::Request
)
else
server_error!(result:)
@@ -44,7 +44,7 @@ def modify
def modify_role
result = CommandTower::UserAttributes::Roles.(user:, admin_user:, roles: params[:roles] || [])
if result.success?
- schema = CommandTower::Schema::User.convert_user_object(user: user.reload)
+ schema = CommandTower::Schema::Shared::User.convert_user_object(user: user.reload)
status = 201
schema_succesful!(status:, schema:)
else
@@ -53,7 +53,7 @@ def modify_role
status: 400,
message: result.msg,
argument_object: result.invalid_argument_hash,
- schema: CommandTower::Schema::PlainText::LoginRequest
+ schema: CommandTower::Schema::Admin::ModifyRole::Request
)
else
server_error!(result:)
diff --git a/app/controllers/command_tower/application_controller.rb b/app/controllers/command_tower/application_controller.rb
index e9734b2..0def03f 100644
--- a/app/controllers/command_tower/application_controller.rb
+++ b/app/controllers/command_tower/application_controller.rb
@@ -2,9 +2,9 @@
module CommandTower
class ApplicationController < ActionController::API
- AUTHENTICATION_HEADER = "Authentication"
- AUTHENTICATION_EXPIRE_HEADER = "X-Authentication-Expire"
- AUTHENTICATION_WITH_RESET = "X-Authentication-Reset"
+ AUTHENTICATION_HEADER = CommandTower::Jwt::AuthorizationHelper::AUTHENTICATION_HEADER
+ AUTHENTICATION_EXPIRE_HEADER = CommandTower::Jwt::AuthorizationHelper::AUTHENTICATION_EXPIRE_HEADER
+ AUTHENTICATION_WITH_RESET = CommandTower::Jwt::AuthorizationHelper::AUTHENTICATION_WITH_RESET
def safe_boolean(value:)
return nil unless [true, false, "true", "false", "0", "1", 0, 1].include?(value)
@@ -13,30 +13,84 @@ def safe_boolean(value:)
end
###
- # Authenticate user via the passed in header
- # AUTHENTICATION_HEADER="Bearer: {token value}"
+ # Authenticate user via the passed in header or cookie
+ # AUTHENTICATION_HEADER="Bearer {token value}" or cookie (if enabled)
def authenticate_user!(bypass_email_validation: false)
- raw_token = request.headers[AUTHENTICATION_HEADER]
- if raw_token.nil?
+ # Extract and validate token (from header or cookie fallback)
+ token_data = CommandTower::Jwt::AuthorizationHelper.extract_token(request)
+ if token_data[:error]
status = 401
- schema = CommandTower::Schema::Error::Base.new(status:, message: "Bearer token missing")
+ schema = CommandTower::Schema::Error::Base.new(status:, message: token_data[:message])
render(json: schema.to_h, status:)
return false
end
- token = raw_token.split("Bearer:")[1].strip
- with_reset = safe_boolean(value: request.headers[AUTHENTICATION_WITH_RESET])
+ token = token_data[:token]
+ token_source = token_data[:source]
+
+ # CSRF validation for cookie-authenticated unsafe requests
+ # Enforcement is centralized here because token_source is only reliably known in this shared filter
+ if csrf_required?(request, token_source)
+ csrf_validation = CommandTower::Jwt::CsrfHelper.validate(request)
+ unless csrf_validation[:valid]
+ status = 403
+ schema = CommandTower::Schema::Error::Base.new(
+ status: status.to_s,
+ message: csrf_validation[:message] # Stable error code: "csrf_missing" or "csrf_mismatch"
+ )
+ render(json: schema.to_h, status: status)
+ return false
+ end
+ end
+
+ with_reset = CommandTower::Jwt::AuthorizationHelper.get_with_reset_flag(request)
result = CommandTower::Jwt::AuthenticateUser.(token:, bypass_email_validation:, with_reset:)
if result.success?
@current_user = result.user
- response.set_header(AUTHENTICATION_EXPIRE_HEADER, result.expires_at)
if with_reset
- response.set_header(AUTHENTICATION_WITH_RESET, result.generated_token)
+ # Use helper to set both headers and cookie (if enabled)
+ CommandTower::Jwt::AuthorizationHelper.set_token(
+ response,
+ result.generated_token,
+ expires_at: result.expires_at
+ )
+
+ # Handle CSRF cookie issuance/rotation for token reset path
+ # CSRF cookie issuance rules:
+ # - rotate_on_reset = true → force rotate (generate new token)
+ # - rotate_on_reset = false → ensure exists only (create if missing, keep if exists)
+ if CommandTower::Jwt::CsrfHelper.csrf_enabled?
+ if CommandTower.config.jwt.cookie.csrf.rotate_on_reset
+ CommandTower::Jwt::CsrfHelper.ensure_cookie(request, response, should_rotate: true)
+ else
+ # Ensure cookie exists (rotate-or-ensure model)
+ CommandTower::Jwt::CsrfHelper.ensure_cookie(request, response, should_rotate: false)
+ end
+ end
+ else
+ # Only set expire header when not resetting
+ response.set_header(AUTHENTICATION_EXPIRE_HEADER, result.expires_at)
end
true
else
- status = 401
- schema = CommandTower::Schema::Error::Base.new(status:, message: result.msg)
+ # Check if this is an email validation failure (status 412)
+ if result.status == 412
+ status = 412
+ schema = CommandTower::Schema::Error::EmailValidationRequired.new(
+ status:,
+ message: result.msg,
+ meta: { email_validated: false }
+ )
+ # Do NOT clear cookie for 412 status - user needs to keep cookie to verify email
+ else
+ # If authentication failed and token came from cookie, clear the invalid cookie
+ # Exception: Do not clear cookie for 412 status (email validation required)
+ if token_source == :cookie
+ CommandTower::Jwt::AuthorizationHelper.clear_cookie(response)
+ end
+ status = result.status || 401
+ schema = CommandTower::Schema::Error::Base.new(status:, message: result.msg)
+ end
render(json: schema.to_h, status:)
# Must return false so callbacks know to halt propagation
false
@@ -77,5 +131,17 @@ def authorize_user!
def current_user
@current_user ||= nil
end
+
+ private
+
+ def csrf_required?(request, token_source)
+ return false unless CommandTower::Jwt::CsrfHelper.csrf_enabled?
+ # CRITICAL: Check token_source, not cookie presence
+ return false unless token_source == :cookie
+ return false if request.get? || request.head? || request.options?
+
+ # Unsafe methods: POST, PUT, PATCH, DELETE
+ %w[POST PUT PATCH DELETE].include?(request.method)
+ end
end
end
diff --git a/app/controllers/command_tower/auth/logout_controller.rb b/app/controllers/command_tower/auth/logout_controller.rb
new file mode 100644
index 0000000..cce5962
--- /dev/null
+++ b/app/controllers/command_tower/auth/logout_controller.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Auth
+ class LogoutController < ::CommandTower::ApplicationController
+ include CommandTower::SchemaHelper
+
+ # POST /auth/logout
+ # Logs out the current browser session by clearing the JWT cookie
+ # Does NOT modify verifier_token (browser-only logout)
+ def logout_post
+ # Clear cookie if cookie auth is enabled
+ CommandTower::Jwt::AuthorizationHelper.clear_token(response)
+
+ schema = CommandTower::Schema::Auth::Logout::Response.new(
+ message: "Logged out"
+ )
+ status = 200
+ schema_succesful!(status:, schema:)
+ end
+ end
+ end
+end
diff --git a/app/controllers/command_tower/auth/plain_text_controller.rb b/app/controllers/command_tower/auth/plain_text_controller.rb
index abc495b..df08eae 100644
--- a/app/controllers/command_tower/auth/plain_text_controller.rb
+++ b/app/controllers/command_tower/auth/plain_text_controller.rb
@@ -4,16 +4,38 @@ class PlainTextController < ::CommandTower::ApplicationController
include CommandTower::SchemaHelper
before_action :authenticate_user_without_email_verification!, only: [:email_verify_post, :email_verify_resend_post]
+ before_action :authenticate_user!, only: [:password_change_post]
# POST /auth/login
# Login to the application and create/set the JWT token
def login_post
result = CommandTower::LoginStrategy::PlainText::Login.(**login_params)
if result.success?
- schema = CommandTower::Schema::PlainText::LoginResponse.new(
+ # Set token in response (headers and cookie if enabled)
+ expires_at = CommandTower.config.jwt.ttl.from_now.to_time.to_s
+ CommandTower::Jwt::AuthorizationHelper.set_token(
+ response,
+ result.token,
+ expires_at: expires_at
+ )
+
+ # Handle CSRF cookie issuance/rotation for login flow (rotate-or-ensure model)
+ # CSRF cookie issuance rules:
+ # - CSRF cookie must ALWAYS exist when CSRF is enabled
+ # - rotate_on_login = false means:
+ # * do NOT force rotation (if cookie exists, keep it)
+ # * but DO create the cookie if missing
+ # - rotate_on_login = true means: always force rotation (generate new token)
+ if CommandTower::Jwt::CsrfHelper.csrf_enabled?
+ config = CommandTower.config.jwt.cookie.csrf
+ # Always ensure cookie exists; rotate_on_login only controls whether to force rotation
+ CommandTower::Jwt::CsrfHelper.ensure_cookie(request, response, should_rotate: config.rotate_on_login)
+ end
+
+ schema = CommandTower::Schema::Auth::PlainText::Login::Response.new(
token: result.token,
header_name: AUTHENTICATION_HEADER,
- user: CommandTower::Schema::User.convert_user_object(user: result.user),
+ user: CommandTower::Schema::Shared::User.convert_user_object(user: result.user),
message: "Successfully logged user in"
)
status = 201
@@ -24,7 +46,7 @@ def login_post
status: 401,
message: result.msg,
argument_object: result.invalid_argument_hash,
- schema: CommandTower::Schema::PlainText::LoginRequest
+ schema: CommandTower::Schema::Auth::PlainText::Login::Request
)
else
json_result = { msg: result.msg }
@@ -34,12 +56,45 @@ def login_post
end
end
+ # GET /auth/login/identifier/valid
+ # Checks if a login identifier is valid
+ def login_identifier_valid_get
+ identifier = params[:identifier]
+ login_key_key = identifier&.include?("@") ? :email : :username
+ result = CommandTower::LoginStrategy::PlainText::ValidIdentifier.(
+ login_key_key: login_key_key,
+ login_key: identifier
+ )
+ if result.success?
+ schema = CommandTower::Schema::Auth::PlainText::LoginIdentifierValid::Response.new(
+ valid: true,
+ message: "Login identifier is valid",
+ user: CommandTower::Schema::Shared::User.convert_user_object(user: result.user)
+ )
+ status = 200
+ schema_succesful!(status:, schema:)
+ else
+ if result.invalid_arguments
+ invalid_arguments!(
+ status: 400,
+ message: result.msg,
+ argument_object: result.invalid_argument_hash,
+ schema: CommandTower::Schema::Auth::PlainText::LoginIdentifierValid::Request
+ )
+ else
+ status = 400
+ schema = CommandTower::Schema::Error::Base.new(status:, message: result.msg)
+ render(json: schema.to_h, status:)
+ end
+ end
+ end
+
# POST /auth/create
# New PlainText user creation
def create_post
result = CommandTower::LoginStrategy::PlainText::Create.(**create_params)
if result.success?
- schema = CommandTower::Schema::PlainText::CreateUserResponse.new(
+ schema = CommandTower::Schema::Auth::PlainText::CreateUser::Response.new(
full_name: result.user.full_name,
first_name: result.first_name,
last_name: result.last_name,
@@ -55,7 +110,7 @@ def create_post
status: 400,
message: result.msg,
argument_object: result.invalid_argument_hash,
- schema: CommandTower::Schema::PlainText::CreateUserRequest
+ schema: CommandTower::Schema::Auth::PlainText::CreateUser::Request
)
end
end
@@ -65,13 +120,13 @@ def create_post
# Verifies a logged in users email verification code when enabled
def email_verify_post
if current_user.email_validated
- schema = CommandTower::Schema::PlainText::EmailVerifyResponse.new(message: "Email is already verified.")
+ schema = CommandTower::Schema::Auth::PlainText::EmailVerify::Response.new(message: "Email is already verified.")
status = 200
schema_succesful!(status:, schema:)
else
result = CommandTower::LoginStrategy::PlainText::EmailVerification::Verify.(user: current_user, code: params[:code])
if result.success?
- schema = CommandTower::Schema::PlainText::EmailVerifyResponse.new(message: "Successfully verified email")
+ schema = CommandTower::Schema::Auth::PlainText::EmailVerify::Response.new(message: "Successfully verified email")
status = 201
schema_succesful!(status:, schema:)
else
@@ -80,7 +135,7 @@ def email_verify_post
status: result.status || 403,
message: result.msg,
argument_object: result.invalid_argument_hash,
- schema: CommandTower::Schema::PlainText::EmailVerifyRequest
+ schema: CommandTower::Schema::Auth::PlainText::EmailVerify::Request
)
end
end
@@ -91,13 +146,13 @@ def email_verify_post
# Sends a logged in users email verification code
def email_verify_resend_post
if current_user.email_validated
- schema = CommandTower::Schema::PlainText::EmailVerifyResponse.new(message: "Email is already verified. No code required")
+ schema = CommandTower::Schema::Auth::PlainText::EmailVerify::SendResponse.new(message: "Email is already verified. No code required")
status = 200
schema_succesful!(status:, schema:)
else
result = CommandTower::LoginStrategy::PlainText::EmailVerification::Send.(user: current_user)
if result.success?
- schema = CommandTower::Schema::PlainText::EmailVerifyResponse.new(message: "Successfully sent Email verification code")
+ schema = CommandTower::Schema::Auth::PlainText::EmailVerify::SendResponse.new(message: "Successfully sent Email verification code")
status = 201
schema_succesful!(status:, schema:)
else
@@ -108,12 +163,117 @@ def email_verify_resend_post
end
end
+ # POST /auth/password/change
+ # Authenticated password change — rotates verifier; does not re-issue JWT
+ def password_change_post
+ result = CommandTower::LoginStrategy::PlainText::ChangePassword.(
+ user: current_user,
+ **password_change_params
+ )
+ if result.success?
+ schema = CommandTower::Schema::Auth::PlainText::ChangePassword::Response.new(
+ message: result.message
+ )
+ status = 200
+ schema_succesful!(status:, schema:)
+ elsif result.invalid_arguments
+ invalid_arguments!(
+ status: 400,
+ message: result.msg,
+ argument_object: result.invalid_argument_hash,
+ schema: CommandTower::Schema::Auth::PlainText::ChangePassword::Request
+ )
+ else
+ status = result.status || 500
+ schema = CommandTower::Schema::Error::Base.new(status:, message: result.msg)
+ render(json: schema.to_h, status:)
+ end
+ end
+
+ # POST /auth/password/forgot/send
+ # Request password reset email
+ def password_forgot_send_post
+ result = CommandTower::LoginStrategy::PlainText::PasswordReset::Send.(**password_forgot_send_params)
+ if result.success?
+ schema = CommandTower::Schema::Auth::PlainText::PasswordForgot::Send::Response.new(
+ message: result.message
+ )
+ status = 200
+ schema_succesful!(status:, schema:)
+ else
+ if result.invalid_arguments
+ invalid_arguments!(
+ status: 400,
+ message: result.msg,
+ argument_object: result.invalid_argument_hash,
+ schema: CommandTower::Schema::Auth::PlainText::PasswordForgot::Send::Request
+ )
+ else
+ schema = CommandTower::Schema::Error::Base.new(status: result.status || 400, message: result.msg)
+ status = result.status || 400
+ render(json: schema.to_h, status:)
+ end
+ end
+ end
+
+ # POST /auth/password/forgot/validate
+ # Validate password reset token
+ def password_forgot_validate_post
+ result = CommandTower::LoginStrategy::PlainText::PasswordReset::Validate.(**password_forgot_validate_params)
+ if result.success?
+ schema = CommandTower::Schema::Auth::PlainText::PasswordForgot::Validate::Response.new(
+ valid: result.valid,
+ expires_at: result.expires_at
+ )
+ status = 200
+ schema_succesful!(status:, schema:)
+ else
+ if result.invalid_arguments
+ invalid_arguments!(
+ status: 400,
+ message: result.msg,
+ argument_object: result.invalid_argument_hash,
+ schema: CommandTower::Schema::Auth::PlainText::PasswordForgot::Validate::Request
+ )
+ else
+ schema = CommandTower::Schema::Error::Base.new(status: result.status || 401, message: result.msg)
+ status = result.status || 401
+ render(json: schema.to_h, status:)
+ end
+ end
+ end
+
+ # POST /auth/password/forgot/reset
+ # Reset password with token
+ def password_forgot_reset_post
+ result = CommandTower::LoginStrategy::PlainText::PasswordReset::Reset.(**password_forgot_reset_params)
+ if result.success?
+ schema = CommandTower::Schema::Auth::PlainText::PasswordForgot::Reset::Response.new(
+ message: result.message
+ )
+ status = 200
+ schema_succesful!(status:, schema:)
+ else
+ if result.invalid_arguments
+ invalid_arguments!(
+ status: 400,
+ message: result.msg,
+ argument_object: result.invalid_argument_hash,
+ schema: CommandTower::Schema::Auth::PlainText::PasswordForgot::Reset::Request
+ )
+ else
+ schema = CommandTower::Schema::Error::Base.new(status: result.status || 401, message: result.msg)
+ status = result.status || 401
+ render(json: schema.to_h, status:)
+ end
+ end
+ end
+
private
def login_params
{
- username: params[:username],
- email: params[:email],
+ identifier: params[:identifier],
password: params[:password],
}
end
@@ -128,6 +288,36 @@ def create_params
password_confirmation: params[:password_confirmation],
}
end
+
+ def password_change_params
+ {
+ current_password: params[:current_password],
+ password: params[:password],
+ password_confirmation: params[:password_confirmation],
+ }
+ end
+
+ def password_forgot_send_params
+ {
+ email: params[:email],
+ }
+ end
+
+ def password_forgot_validate_params
+ {
+ token: params[:token],
+ email: params[:email],
+ }
+ end
+
+ def password_forgot_reset_params
+ {
+ token: params[:token],
+ email: params[:email],
+ password: params[:password],
+ password_confirmation: params[:password_confirmation],
+ }
+ end
end
end
end
diff --git a/app/controllers/command_tower/inbox/message_blast_controller.rb b/app/controllers/command_tower/inbox/message_blast_controller.rb
index d0feefd..69aea83 100644
--- a/app/controllers/command_tower/inbox/message_blast_controller.rb
+++ b/app/controllers/command_tower/inbox/message_blast_controller.rb
@@ -26,7 +26,7 @@ def blast
status: 400,
message: result.msg,
argument_object: result.invalid_argument_hash,
- schema: CommandTower::Schema::PlainText::LoginRequest,
+ schema: CommandTower::Schema::Inbox::Blast::Show::Request,
)
end
end
@@ -53,7 +53,7 @@ def delete
status: 400,
message: result.msg,
argument_object: result.invalid_argument_hash,
- schema: CommandTower::Schema::PlainText::LoginRequest,
+ schema: CommandTower::Schema::Inbox::Blast::Delete::Request,
)
end
end
@@ -76,11 +76,12 @@ def upsert(id: nil)
status = 200
schema_succesful!(status:, schema:)
else
+ request_schema = id ? CommandTower::Schema::Inbox::Blast::Modify::Request : CommandTower::Schema::Inbox::Blast::Create::Request
invalid_arguments!(
status: 400,
message: result.msg,
argument_object: result.invalid_argument_hash,
- schema: CommandTower::Schema::Inbox::BlastRequest
+ schema: request_schema
)
end
end
diff --git a/app/controllers/command_tower/inbox/message_controller.rb b/app/controllers/command_tower/inbox/message_controller.rb
index 9af1d2f..97af53a 100644
--- a/app/controllers/command_tower/inbox/message_controller.rb
+++ b/app/controllers/command_tower/inbox/message_controller.rb
@@ -21,7 +21,7 @@ def metadata
status: 400,
message: result.msg,
argument_object: result.invalid_argument_hash,
- schema: CommandTower::Schema::PlainText::LoginRequest
+ schema: CommandTower::Schema::Inbox::Messages::Metadata::Request
)
end
end
@@ -38,7 +38,7 @@ def message
status: 400,
message: result.msg,
argument_object: result.invalid_argument_hash,
- schema: CommandTower::Schema::PlainText::LoginRequest
+ schema: CommandTower::Schema::Inbox::Messages::Message::Request
)
end
end
@@ -68,11 +68,19 @@ def modify(type:)
status = 200
schema_succesful!(status:, schema:)
else
+ error_schema = case type
+ when CommandTower::InboxService::Message::Modify::VIEWED
+ CommandTower::Schema::Inbox::Messages::Ack::Request
+ when CommandTower::InboxService::Message::Modify::DELETE
+ CommandTower::Schema::Inbox::Messages::Delete::Request
+ else
+ CommandTower::Schema::Inbox::Messages::Ack::Request
+ end
invalid_arguments!(
status: 400,
message: result.msg,
argument_object: result.invalid_argument_hash,
- schema: CommandTower::Schema::PlainText::LoginRequest
+ schema: error_schema
)
end
end
diff --git a/app/controllers/command_tower/user_controller.rb b/app/controllers/command_tower/user_controller.rb
index 070ca7c..937e633 100644
--- a/app/controllers/command_tower/user_controller.rb
+++ b/app/controllers/command_tower/user_controller.rb
@@ -7,14 +7,14 @@ class UserController < ::CommandTower::ApplicationController
before_action :authenticate_user!
def show
- schema = CommandTower::Schema::User.convert_user_object(user: current_user)
+ schema = CommandTower::Schema::Shared::User.convert_user_object(user: current_user)
schema_succesful!(status: 200, schema:)
end
def modify
result = CommandTower::UserAttributes::Modify.(user: current_user, **modify_params)
if result.success?
- schema = CommandTower::Schema::User.convert_user_object(user: current_user.reload)
+ schema = CommandTower::Schema::Shared::User.convert_user_object(user: current_user.reload)
status = 201
schema_succesful!(status:, schema:)
else
@@ -23,7 +23,7 @@ def modify
status: 400,
message: result.msg,
argument_object: result.invalid_argument_hash,
- schema: CommandTower::Schema::PlainText::LoginRequest
+ schema: CommandTower::Schema::User::Modify::Request
)
else
status = 500
diff --git a/app/controllers/command_tower/username_controller.rb b/app/controllers/command_tower/username_controller.rb
index 084409e..51c7e81 100644
--- a/app/controllers/command_tower/username_controller.rb
+++ b/app/controllers/command_tower/username_controller.rb
@@ -17,7 +17,7 @@ def username_availability
else
json_result = { msg: result.msg }
json_result[:invalid_arguments] = true if result.invalid_arguments
- status = 401
+ status = 400
end
render json: json_result, status: status
diff --git a/app/helpers/command_tower/schema_helper.rb b/app/helpers/command_tower/schema_helper.rb
index 9d234eb..c2c0bca 100644
--- a/app/helpers/command_tower/schema_helper.rb
+++ b/app/helpers/command_tower/schema_helper.rb
@@ -3,7 +3,27 @@
module CommandTower
module SchemaHelper
def schema_succesful!(schema:, status:)
- render(json: schema.to_h, status:)
+ schema_hash = schema.to_h
+ # Fix: Ensure pagination is an empty hash when nil or missing, so it responds to empty?
+ # Only apply this fix to schemas that have a pagination field defined
+ has_pagination_field = false
+ if schema.class.respond_to?(:introspect)
+ introspect_result = schema.class.introspect
+ has_pagination_field = introspect_result.is_a?(Hash) && (introspect_result.key?(:pagination) || introspect_result.key?("pagination"))
+ end
+
+ # Also check if pagination key exists in the hash itself (fallback)
+ has_pagination_in_hash = schema_hash.key?("pagination") || schema_hash.key?(:pagination)
+
+ if has_pagination_field || has_pagination_in_hash
+ # Check if pagination key exists (as string or symbol) and if value is nil
+ pagination_val = schema_hash["pagination"] || schema_hash[:pagination]
+ if pagination_val.nil?
+ # Value is nil, set it to empty hash (use string key for consistency with JSON)
+ schema_hash["pagination"] = {}
+ end
+ end
+ render(json: schema_hash, status:)
end
def invalid_arguments!(message:, argument_object:, schema:, status:)
diff --git a/app/mailers/command_tower/email_verification_mailer.rb b/app/mailers/command_tower/email_verification_mailer.rb
index ecb10ed..4b3b0e0 100644
--- a/app/mailers/command_tower/email_verification_mailer.rb
+++ b/app/mailers/command_tower/email_verification_mailer.rb
@@ -2,11 +2,13 @@
module CommandTower
class EmailVerificationMailer < ApplicationMailer
- def verify_email(email, user, code)
- subject = "Welcome to #{}"
+ def verify_email(email, user, code, template_name: nil)
+ subject = "Welcome to #{CommandTower.config.app.communication_name }"
@user = user
@code = code
- mail(to: email, subject:)
+
+ template = template_name || "verify_email"
+ mail(to: email, subject:, template_name: template)
end
end
end
diff --git a/app/mailers/command_tower/password_reset_mailer.rb b/app/mailers/command_tower/password_reset_mailer.rb
new file mode 100644
index 0000000..11a7653
--- /dev/null
+++ b/app/mailers/command_tower/password_reset_mailer.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module CommandTower
+ class PasswordResetMailer < ApplicationMailer
+ def reset_password(email, user, token, template_name: nil)
+ subject = "Reset Your Password for #{CommandTower.config.app.communication_name}"
+ @user = user
+ @token = token
+ @email = email
+ password_reset_config = CommandTower.config.login.plain_text.password_reset
+ @require_email = password_reset_config.require_email
+ @reset_password_path = password_reset_config.reset_password_path
+
+ template = template_name || password_reset_config.custom_template_name || "reset_password"
+ mail(to: email, subject:, template_name: template)
+ end
+ end
+end
diff --git a/app/services/command_tower/admin_service/users.rb b/app/services/command_tower/admin_service/users.rb
index d7a93f8..fb2dfb1 100644
--- a/app/services/command_tower/admin_service/users.rb
+++ b/app/services/command_tower/admin_service/users.rb
@@ -11,8 +11,8 @@ class Users < CommandTower::ServiceBase
validate :user, is_a: User, required: true
def call
- schemafied_users = query.map { CommandTower::Schema::User.convert_user_object(user: _1) }
- context.schema = CommandTower::Schema::Admin::Users.new(
+ schemafied_users = query.map { CommandTower::Schema::Shared::User.convert_user_object(user: _1) }
+ context.schema = CommandTower::Schema::Shared::Admin::Users.new(
users: schemafied_users,
count: schemafied_users.count,
pagination: pagination_schema,
diff --git a/app/services/command_tower/inbox_service/blast/metadata.rb b/app/services/command_tower/inbox_service/blast/metadata.rb
index 7802b13..ce71ca6 100644
--- a/app/services/command_tower/inbox_service/blast/metadata.rb
+++ b/app/services/command_tower/inbox_service/blast/metadata.rb
@@ -6,7 +6,7 @@ module Blast
class Metadata < CommandTower::ServiceBase
def call
entities = ::MessageBlast.all.select(:id, :title, :existing_users, :new_users).map do |mb|
- CommandTower::Schema::Inbox::MessageBlastEntity.new(
+ CommandTower::Schema::Entities::Inbox::MessageBlastEntity.new(
title: mb.title,
id: mb.id,
existing_users: mb.existing_users,
@@ -15,7 +15,7 @@ def call
end
- context.metadata = CommandTower::Schema::Inbox::MessageBlastMetadata.new(
+ context.metadata = CommandTower::Schema::Shared::Inbox::MessageBlastMetadata.new(
entities: entities,
count: entities.length,
)
diff --git a/app/services/command_tower/inbox_service/blast/retrieve.rb b/app/services/command_tower/inbox_service/blast/retrieve.rb
index 6da0f6e..d4745f9 100644
--- a/app/services/command_tower/inbox_service/blast/retrieve.rb
+++ b/app/services/command_tower/inbox_service/blast/retrieve.rb
@@ -15,8 +15,8 @@ def call
inline_argument_failure!(errors: { id: "MessageBlast ID not found" })
end
- context.message_blast = CommandTower::Schema::Inbox::MessageBlastEntity.new(
- created_by: CommandTower::Schema::User.convert_user_object(user: message_blast.user),
+ context.message_blast = CommandTower::Schema::Entities::Inbox::MessageBlastEntity.new(
+ created_by: CommandTower::Schema::Shared::User.convert_user_object(user: message_blast.user),
title: message_blast.title,
text: message_blast.text,
id: message_blast.id,
diff --git a/app/services/command_tower/inbox_service/blast/upsert.rb b/app/services/command_tower/inbox_service/blast/upsert.rb
index a75d69f..a126bd1 100644
--- a/app/services/command_tower/inbox_service/blast/upsert.rb
+++ b/app/services/command_tower/inbox_service/blast/upsert.rb
@@ -27,13 +27,13 @@ def call
blast_messages!(message_blast: ar)
context.message_blast = ar
- context.blast = CommandTower::Schema::Inbox::BlastResponse.new(
+ context.blast = CommandTower::Schema::Inbox::Blast::Create::Response.new(
existing_users:,
new_users:,
text:,
title:,
id: ar.id,
- created_by: CommandTower::Schema::User.convert_user_object(user:)
+ created_by: CommandTower::Schema::Shared::User.convert_user_object(user:)
)
end
diff --git a/app/services/command_tower/inbox_service/message/metadata.rb b/app/services/command_tower/inbox_service/message/metadata.rb
index 425b277..fd697f9 100644
--- a/app/services/command_tower/inbox_service/message/metadata.rb
+++ b/app/services/command_tower/inbox_service/message/metadata.rb
@@ -12,10 +12,11 @@ class Metadata < CommandTower::ServiceBase
def call
entities = query.map do |message|
- CommandTower::Schema::Inbox::MessageEntity.new(
+ CommandTower::Schema::Entities::Inbox::MessageEntity.new(
id: message.id,
title: message.title,
viewed: message.viewed,
+ created_at: message.created_at.iso8601,
)
end
@@ -24,18 +25,13 @@ def call
entities: entities.nil? ? nil : entities,
pagination: pagination_schema,
}.compact
- context.metadata = CommandTower::Schema::Inbox::Metadata.new(**params)
+ context.metadata = CommandTower::Schema::Shared::Inbox::Metadata.new(**params)
end
def default_query
- ::Message.where(user:).select(:id, :title, :viewed)
+ ::Message.where(user:).select(:id, :title, :viewed, :created_at)
end
end
end
end
end
-
-
-
-
-
diff --git a/app/services/command_tower/inbox_service/message/modify.rb b/app/services/command_tower/inbox_service/message/modify.rb
index 497e24a..1642b3e 100644
--- a/app/services/command_tower/inbox_service/message/modify.rb
+++ b/app/services/command_tower/inbox_service/message/modify.rb
@@ -27,7 +27,7 @@ def call
modified_ids = messages.destroy_all.pluck(:id)
end
- context.modified = CommandTower::Schema::Inbox::Modified.new(
+ context.modified = CommandTower::Schema::Shared::Inbox::Modified.new(
ids: modified_ids,
type:,
count: modified_ids.length,
@@ -37,8 +37,3 @@ def call
end
end
end
-
-
-
-
-
diff --git a/app/services/command_tower/inbox_service/message/retrieve.rb b/app/services/command_tower/inbox_service/message/retrieve.rb
index 052198b..6782592 100644
--- a/app/services/command_tower/inbox_service/message/retrieve.rb
+++ b/app/services/command_tower/inbox_service/message/retrieve.rb
@@ -18,19 +18,15 @@ def call
message.update!(viewed: true)
- context.message = CommandTower::Schema::Inbox::MessageEntity.new(
+ context.message = CommandTower::Schema::Entities::Inbox::MessageEntity.new(
title: message.title,
id: message.id,
text: message.text,
viewed: message.viewed,
+ created_at: message.created_at.iso8601,
)
end
end
end
end
end
-
-
-
-
-
diff --git a/app/services/command_tower/jwt/authenticate_user.rb b/app/services/command_tower/jwt/authenticate_user.rb
index 2e868a8..7e5c30d 100644
--- a/app/services/command_tower/jwt/authenticate_user.rb
+++ b/app/services/command_tower/jwt/authenticate_user.rb
@@ -79,7 +79,7 @@ def email_validation_required!(user:)
result = CommandTower::LoginStrategy::PlainText::EmailVerification::Required.(user:)
if result.required
- context.fail!(msg: "User's Email must be validated before they can continue")
+ context.fail!(msg: "Email must be verified to continue", status: 412)
end
end
end
diff --git a/app/services/command_tower/login_strategy/plain_text/change_password.rb b/app/services/command_tower/login_strategy/plain_text/change_password.rb
new file mode 100644
index 0000000..be280dd
--- /dev/null
+++ b/app/services/command_tower/login_strategy/plain_text/change_password.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+module CommandTower::LoginStrategy::PlainText
+ class ChangePassword < CommandTower::ServiceBase
+ on_argument_validation :fail_early
+
+ validate :user, is_a: User, required: true
+ validate :current_password, is_a: String, required: true, sensitive: true
+ validate :password, is_a: String, required: true, sensitive: true
+ validate :password_confirmation, is_a: String, required: true, sensitive: true
+
+ def call
+ unless user.authenticate(current_password)
+ inline_argument_failure!(errors: { current_password: "Incorrect current password" })
+ return
+ end
+
+ unless password == password_confirmation
+ inline_argument_failure!(errors: { password_confirmation: "Password and confirmation do not match" })
+ return
+ end
+
+ password_config = CommandTower.config.login.plain_text
+ if password.length < password_config.password_length_min || password.length > password_config.password_length_max
+ inline_argument_failure!(
+ errors: {
+ password: "Password length must be between #{password_config.password_length_min} and #{password_config.password_length_max} characters",
+ }
+ )
+ return
+ end
+
+ persistence_errors = nil
+ infrastructure_failure = false
+
+ ActiveRecord::Base.transaction do
+ user.password = password
+ user.password_confirmation = password_confirmation
+
+ unless user.save
+ persistence_errors = user.errors
+ raise ActiveRecord::Rollback
+ end
+
+ begin
+ user.reset_verifier_token!
+ rescue StandardError => e
+ log_error("Failed to rotate session verifier for user [#{user.id}]: #{e.class}")
+ infrastructure_failure = true
+ raise ActiveRecord::Rollback
+ end
+ end
+
+ if persistence_errors
+ log_error("Failed to update password for user [#{user.id}]")
+ inline_argument_failure!(errors: persistence_errors)
+ return
+ end
+
+ if infrastructure_failure
+ context.fail!(msg: "Failed to rotate session verifier", status: 500)
+ return
+ end
+
+ # Detect silent rollback (neither branch set) — should not occur in normal paths
+ user.reload
+ unless user.authenticate(password)
+ log_error("Password change transaction did not persist for user [#{user.id}]")
+ context.fail!(msg: "Failed to change password", status: 500)
+ return
+ end
+
+ log_info("Password changed successfully for user [#{user.id}]")
+ context.message = "Password has been successfully changed"
+ context.user_id = user.id
+ end
+ end
+end
diff --git a/app/services/command_tower/login_strategy/plain_text/email_verification/send.rb b/app/services/command_tower/login_strategy/plain_text/email_verification/send.rb
index 1d3a60c..f00c4cb 100644
--- a/app/services/command_tower/login_strategy/plain_text/email_verification/send.rb
+++ b/app/services/command_tower/login_strategy/plain_text/email_verification/send.rb
@@ -13,7 +13,8 @@ def call
end
begin
- CommandTower::EmailVerificationMailer.verify_email(user.email, user, result.secret).deliver
+ template_name = CommandTower.config.login.plain_text.email_verify.custom_template_name
+ CommandTower::EmailVerificationMailer.verify_email(user.email, user, result.secret, template_name: template_name).deliver
rescue StandardError => e
log_error("Failed to send message to [#{user.id}]: #{e.message}")
context.fail!(msg: "Unable to send email. Please try again later", status: 500)
diff --git a/app/services/command_tower/login_strategy/plain_text/login.rb b/app/services/command_tower/login_strategy/plain_text/login.rb
index 6308a1c..b4806e9 100644
--- a/app/services/command_tower/login_strategy/plain_text/login.rb
+++ b/app/services/command_tower/login_strategy/plain_text/login.rb
@@ -4,16 +4,16 @@ module CommandTower::LoginStrategy::PlainText
class Login < CommandTower::ServiceBase
on_argument_validation :fail_early
- one_of(:login_key, required: true) do
- validate :username, is_a: String, sensitive: true
- validate :email, is_a: String, sensitive: true
- end
+ validate :identifier, is_a: String, required: true, sensitive: true
validate :password, is_a: String, required: true, sensitive: true
def call
if user.nil?
- log_warn("Login attempted with [#{login_key_key}] => [#{login_key}]. Resource not found")
- credential_mismatch!
+ msg = "Unauthorized Access. Incorrect Credentials"
+ invalid_argument_hash = { identifier: { msg: }, password: { msg: } }
+ invalid_argument_keys = [:identifier, :password]
+ context.fail!(msg:, invalid_argument_hash:, invalid_argument_keys:, invalid_arguments: true)
+ return
end
if user.authenticate(password)
@@ -23,7 +23,7 @@ def call
else
user.password_consecutive_fail += 1
user.save
- log_warn("Valid #{login_key_key}. Incorrect password. Consecutive Password failures: #{user.password_consecutive_fail}")
+ log_warn("Valid identifier. Incorrect password. Consecutive Password failures: #{user.password_consecutive_fail}")
credential_mismatch!
end
@@ -40,11 +40,11 @@ def call
def credential_mismatch!
msg = "Unauthorized Access. Incorrect Credentials"
- inline_argument_failure!(errors: { login_key_key => msg, password: msg })
+ inline_argument_failure!(errors: { identifier: msg, password: msg })
end
def user
- @user ||= User.where(login_key_key => login_key).first
+ @user ||= User.where(username: identifier).or(User.where(email: identifier)).first
end
end
end
diff --git a/app/services/command_tower/login_strategy/plain_text/password_reset/helper.rb b/app/services/command_tower/login_strategy/plain_text/password_reset/helper.rb
new file mode 100644
index 0000000..8fcaa0a
--- /dev/null
+++ b/app/services/command_tower/login_strategy/plain_text/password_reset/helper.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+module CommandTower::LoginStrategy::PlainText::PasswordReset
+ module Helper
+ def validate_email_required!
+ return unless password_reset_configuration.require_email
+ return unless email.nil? || email.to_s.strip.empty?
+
+ context.fail!(msg: "Email is required", status: 400)
+ end
+
+ def validate_email_match!(token_user:)
+ # If email is provided, always validate it matches (regardless of require_email setting)
+ return unless email && !email.to_s.strip.empty?
+
+ normalized_provided_email = email.to_s.downcase.strip
+ normalized_user_email = token_user.email.to_s.downcase.strip
+ return if normalized_provided_email == normalized_user_email
+
+ log_warn("Email mismatch for user #{token_user.id}: #{token_user.email} != #{email}")
+ context.fail!(msg: "Invalid token", status: 401)
+ end
+
+ def validate_token!
+ verify_result = CommandTower::Secrets::Verify.(
+ secret: token,
+ reason: CommandTower::Secrets::PASSWORD_RESET,
+ access_count: access_count
+ )
+
+ return verify_result if verify_result.success?
+
+ log_token_validation_failure(verify_result)
+ context.fail!(msg: "Invalid token", status: 401)
+ nil
+ end
+
+ def get_user_from_token!(verify_result)
+ return nil if verify_result.nil? || verify_result.failure?
+
+ verify_result.user
+ end
+
+ def get_expires_at_from_token(verify_result)
+ return nil if verify_result.nil? || verify_result.failure?
+
+ # Query UserSecret for expires_at (death_time)
+ # Note: This is a single indexed lookup, optimized by secret uniqueness
+ user_secret = UserSecret.find_by(secret: token, reason: CommandTower::Secrets::PASSWORD_RESET)
+ user_secret&.death_time
+ end
+
+ def log_token_validation_failure(verify_result)
+ return unless verify_result
+
+ record_data = verify_result.record
+ if record_data && record_data[:found]
+ record = record_data[:record]
+ invalid_reasons = record.invalid_reason
+ log_warn("Password reset token validation failed for token: #{token[0..10]}... Reasons: #{invalid_reasons.join(', ')}")
+ else
+ log_warn("Password reset token not found: #{token[0..10]}...")
+ end
+ end
+
+ def password_reset_configuration
+ CommandTower.config.login.plain_text.password_reset
+ end
+
+ def access_count
+ # Override in services that need different access_count behavior
+ false
+ end
+ end
+end
diff --git a/app/services/command_tower/login_strategy/plain_text/password_reset/reset.rb b/app/services/command_tower/login_strategy/plain_text/password_reset/reset.rb
new file mode 100644
index 0000000..ddc4b0f
--- /dev/null
+++ b/app/services/command_tower/login_strategy/plain_text/password_reset/reset.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+module CommandTower::LoginStrategy::PlainText::PasswordReset
+ class Reset < CommandTower::ServiceBase
+ include Helper
+
+ on_argument_validation :fail_early
+
+ validate :token, is_a: String, required: true, sensitive: true
+ validate :email, is_a: String, required: false
+ validate :password, is_a: String, required: true, sensitive: true
+ validate :password_confirmation, is_a: String, required: true, sensitive: true
+
+ def call
+ # Validate password confirmation matches
+ unless password == password_confirmation
+ inline_argument_failure!(errors: { password_confirmation: "Password and confirmation do not match" })
+ return
+ end
+
+ # Validate password length
+ password_config = CommandTower.config.login.plain_text
+ if password.length < password_config.password_length_min || password.length > password_config.password_length_max
+ inline_argument_failure!(errors: { password: "Password length must be between #{password_config.password_length_min} and #{password_config.password_length_max} characters" })
+ return
+ end
+
+ validate_email_required!
+ return if context.failure?
+
+ verify_result = validate_token!
+ return if context.failure?
+
+ user = get_user_from_token!(verify_result)
+ return if context.failure?
+
+ validate_email_match!(token_user: user)
+ return if context.failure?
+
+ log_info("Password reset token verified successfully for user [#{user.id}]")
+
+ # Update user password
+ user.password = password
+ user.password_confirmation = password_confirmation
+
+ if user.save
+ log_info("Password reset successfully completed for user [#{user.id}]")
+ context.message = "Password has been successfully reset"
+ else
+ log_error("Failed to update password for user [#{user.id}]: #{user.errors.full_messages.join(', ')}")
+ inline_argument_failure!(errors: user.errors)
+ end
+ end
+
+ def access_count
+ true
+ end
+ end
+end
diff --git a/app/services/command_tower/login_strategy/plain_text/password_reset/send.rb b/app/services/command_tower/login_strategy/plain_text/password_reset/send.rb
new file mode 100644
index 0000000..cf2406e
--- /dev/null
+++ b/app/services/command_tower/login_strategy/plain_text/password_reset/send.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+module CommandTower::LoginStrategy::PlainText::PasswordReset
+ class Send < CommandTower::ServiceBase
+ on_argument_validation :fail_early
+
+ validate :email, is_a: String, required: true
+
+ def call
+ # Validate email format
+ unless email =~ URI::MailTo::EMAIL_REGEXP
+ inline_argument_failure!(errors: { email: "Invalid email address" })
+ return
+ end
+
+ # Find user by email (silently, no error if not found)
+ user = User.find_by(email: email.downcase.strip)
+
+ if user.nil?
+ log_info("Password reset requested for email that doesn't exist: #{email.downcase.strip}")
+ context.message = "If an account exists with that email, a password reset link has been sent."
+ return
+ end
+
+ log_info("Password reset requested for user [#{user.id}] with email: #{user.email}")
+
+ # Generate token using Secrets::Generate
+ result = CommandTower::Secrets::Generate.(
+ user: user,
+ secret_length: password_reset_config.token_length,
+ reason: CommandTower::Secrets::PASSWORD_RESET,
+ use_count_max: 1,
+ death_time: password_reset_config.token_valid_for,
+ type: CommandTower::Secrets::ALPHANUMERIC,
+ cleanse: true,
+ )
+ # Always return success message (security: prevent user enumeration)
+ context.message = "If an account exists with that email, a password reset link has been sent."
+
+ if result.failure?
+ log_error("Failed to generate password reset token for user [#{user.id}]: #{result.msg}")
+ # Still return success to prevent user enumeration
+ return
+ end
+
+ log_info("Password reset token generated successfully for user [#{user.id}]")
+
+ # Send email with token
+ begin
+ template_name = password_reset_config.custom_template_name
+ CommandTower::PasswordResetMailer.reset_password(user.email, user, result.secret, template_name: template_name).deliver
+ log_info("Password reset email sent successfully to user [#{user.id}]")
+ rescue StandardError => e
+ log_error("Failed to send password reset email to user [#{user.id}]: #{e.message}")
+ # CRITICAL: Still return success to prevent user enumeration
+ # Never return 500 as it would reveal email exists in system
+ end
+ end
+
+ private
+
+ def password_reset_config
+ CommandTower.config.login.plain_text.password_reset
+ end
+ end
+end
diff --git a/app/services/command_tower/login_strategy/plain_text/password_reset/validate.rb b/app/services/command_tower/login_strategy/plain_text/password_reset/validate.rb
new file mode 100644
index 0000000..11745d3
--- /dev/null
+++ b/app/services/command_tower/login_strategy/plain_text/password_reset/validate.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module CommandTower::LoginStrategy::PlainText::PasswordReset
+ class Validate < CommandTower::ServiceBase
+ include Helper
+
+ on_argument_validation :fail_early
+
+ validate :token, is_a: String, required: true, sensitive: true
+ validate :email, is_a: String, required: false
+
+ def call
+ validate_email_required!
+ return if context.failure?
+
+ verify_result = validate_token!
+ return if context.failure?
+
+ user = get_user_from_token!(verify_result)
+ return if context.failure?
+
+ validate_email_match!(token_user: user)
+ return if context.failure?
+
+ expires_at = get_expires_at_from_token(verify_result)
+
+ log_info("Password reset token validated successfully for user [#{user.id}]")
+
+ context.valid = true
+ context.expires_at = expires_at&.to_s
+ end
+ end
+end
diff --git a/app/services/command_tower/login_strategy/plain_text/valid_identifier.rb b/app/services/command_tower/login_strategy/plain_text/valid_identifier.rb
new file mode 100644
index 0000000..d6ac28d
--- /dev/null
+++ b/app/services/command_tower/login_strategy/plain_text/valid_identifier.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module CommandTower::LoginStrategy::PlainText
+ class ValidIdentifier < CommandTower::ServiceBase
+ on_argument_validation :fail_early
+
+ validate :login_key_key, is_a: Symbol, required: true, sensitive: true
+ validate :login_key, is_a: String, required: true, sensitive: true
+
+
+ def call()
+ if user.nil?
+ log_warn("Login identifier not found: [#{login_key_key}] => [#{login_key}]")
+ credential_mismatch!
+ return
+ end
+
+ context.user = user
+ end
+
+ def credential_mismatch!
+ msg = "Unauthorized Access. Incorrect Credentials"
+ inline_argument_failure!(errors: { login_key_key => msg })
+ end
+
+ def user
+ @user ||= ::User.where(login_key_key => login_key).first
+ end
+ end
+end
diff --git a/app/services/command_tower/pagination_service_helper.rb b/app/services/command_tower/pagination_service_helper.rb
index 5cb0314..3ff1849 100644
--- a/app/services/command_tower/pagination_service_helper.rb
+++ b/app/services/command_tower/pagination_service_helper.rb
@@ -22,11 +22,11 @@ def pagination_schema
cursor: pagination_params[:offset],
}
query = base_params.to_query
- current = CommandTower::Schema::Page.new(query:, **base_params)
+ current = CommandTower::Schema::Shared::Page.new(query:, **base_params)
base_params[:cursor] += pagination_params[:limit]
query = base_params.to_query
- next_page = CommandTower::Schema::Page.new(query:, **base_params)
+ next_page = CommandTower::Schema::Shared::Page.new(query:, **base_params)
count_available = default_query.size
total_pages = count_available / pagination_params[:limit]
@@ -40,14 +40,15 @@ def pagination_schema
# current_page = [, 1].max
# Ensure we cannot go negative when no elements are returned
remaining_pages = [total_pages - current_page, 0].max
+ has_more = remaining_pages > 0
- CommandTower::Schema::Pagination.new(
+ CommandTower::Schema::Shared::Pagination.new(
count_available:,
total_pages:,
current_page:,
remaining_pages:,
current:,
- next: next_page,
+ next: has_more ? next_page : nil,
)
end
diff --git a/app/services/command_tower/secrets.rb b/app/services/command_tower/secrets.rb
index fc1d838..49b7662 100644
--- a/app/services/command_tower/secrets.rb
+++ b/app/services/command_tower/secrets.rb
@@ -3,6 +3,7 @@
module CommandTower::Secrets
ALLOWED_SECRET_REASONS = [
EMAIL_VERIFICIATION = :email_verification,
+ PASSWORD_RESET = :password_reset,
SSO = :sso,
]
diff --git a/app/views/command_tower/email_verification_mailer/verify_email.html.erb b/app/views/command_tower/email_verification_mailer/verify_email.html.erb
index a9cf0be..792d8f1 100644
--- a/app/views/command_tower/email_verification_mailer/verify_email.html.erb
+++ b/app/views/command_tower/email_verification_mailer/verify_email.html.erb
@@ -1,26 +1,74 @@
-
Hello <%= @user.full_name %>!
+
+
+
+
+
+
+
+
+ Welcome to <%= CommandTower.config.app.communication_name %>!
+
+ |
+
-
- Welcome to <%= CommandTower.config.app.communication_name %>.
-
-
- Please provide the following verification code to confirm your email address.
-
-
-
-
-
- <%= @code %>
-
-
+
+
+ |
+
+ Hello <%= @user.full_name %>,
+
-
-
+
+ Thank you for joining us! To complete your registration and verify your email address, please use the verification code below:
+
-
- Best wishes, <%= CommandTower.config.app.communication_name %> team
-
-
-
+
+
+
+ |
+
+ <%= @code %>
+
+
+ Verification Code
+
+ |
+
+
-Visit us at <%= CommandTower.config.app.composed_url %>
+
+ Important: This code will expire in <%= CommandTower.config.login.plain_text.email_verify.verify_code_link_valid_for.inspect %>. If you didn't request this code, please ignore this email.
+
+ |
+
+
+
+
+ |
+
+ Best regards,
+ <%= CommandTower.config.app.communication_name %> Team
+
+
+
+
+ Visit <%= CommandTower.config.app.communication_name %>
+
+
+ |
+
+
+
+
+
+
+ |
+
+ This is an automated message. Please do not reply to this email.
+
+ |
+
+
+ |
+
+
diff --git a/app/views/command_tower/password_reset_mailer/reset_password.html.erb b/app/views/command_tower/password_reset_mailer/reset_password.html.erb
new file mode 100644
index 0000000..92a3d69
--- /dev/null
+++ b/app/views/command_tower/password_reset_mailer/reset_password.html.erb
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+ Reset Your Password
+
+ |
+
+
+
+
+ |
+
+ Hello <%= @user.full_name %>,
+
+
+
+ We received a request to reset your password for your <%= CommandTower.config.app.communication_name %> account. Click the button below to reset your password:
+
+
+
+
+
+ |
+
+ <%= @token %>
+
+
+ Reset Token
+
+ |
+
+
+
+
+
+
+ |
+ <% reset_url = "#{CommandTower.config.app.composed_url}#{@reset_password_path}?token=#{@token}" %>
+ <% reset_url += "&email=#{CGI.escape(@email)}" if @require_email %>
+
+ Reset Password
+
+ |
+
+
+
+
+ Important: This link will expire in <%= CommandTower.config.login.plain_text.password_reset.token_valid_for.inspect %>. If you didn't request a password reset, please ignore this email. Your password will remain unchanged.
+
+
+
+ If the button above doesn't work, copy and paste the reset token above into the password reset form on our website.
+
+
+
+
+ |
+
+
+
+
+ |
+
+ Best regards,
+ <%= CommandTower.config.app.communication_name %> Team
+
+
+
+
+ Visit <%= CommandTower.config.app.communication_name %>
+
+
+ |
+
+
+
+
+
+
+ |
+
+ This is an automated message. Please do not reply to this email.
+
+ |
+
+
+ |
+
+
diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb
new file mode 100644
index 0000000..3aac900
--- /dev/null
+++ b/app/views/layouts/mailer.html.erb
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+ <%= yield %>
+
+
diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb
new file mode 100644
index 0000000..37f0bdd
--- /dev/null
+++ b/app/views/layouts/mailer.text.erb
@@ -0,0 +1 @@
+<%= yield %>
diff --git a/config/routes.rb b/config/routes.rb
index 9de95d4..71a38ae 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -8,6 +8,8 @@
end
scope "auth" do
+ post "/logout", to: "command_tower/auth/logout#logout_post", as: :"#{append_to_ass}_auth_logout_post"
+
constraints(->(_req) { CommandTower.config.login.plain_text.enable? }) do
post "/login", to: "command_tower/auth/plain_text#login_post", as: :"#{append_to_ass}_auth_login_post"
post "/create", to: "command_tower/auth/plain_text#create_post", as: :"#{append_to_ass}_auth_create_post"
@@ -18,6 +20,18 @@
post "/send", to: "command_tower/auth/plain_text#email_verify_resend_post", as: :"#{append_to_ass}_auth_email_verification_send"
end
end
+
+ scope "password" do
+ post "/change", to: "command_tower/auth/plain_text#password_change_post", as: :"#{append_to_ass}_auth_password_change_post"
+
+ constraints(->(_req) { CommandTower.config.login.plain_text.password_reset? }) do
+ scope "forgot" do
+ post "/send", to: "command_tower/auth/plain_text#password_forgot_send_post", as: :"#{append_to_ass}_auth_password_forgot_send_post"
+ post "/validate", to: "command_tower/auth/plain_text#password_forgot_validate_post", as: :"#{append_to_ass}_auth_password_forgot_validate_post"
+ post "/reset", to: "command_tower/auth/plain_text#password_forgot_reset_post", as: :"#{append_to_ass}_auth_password_forgot_reset_post"
+ end
+ end
+ end
end
end
diff --git a/docker-compose.yaml b/docker-compose.yaml
index f3b8588..7054857 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -19,7 +19,7 @@ x-common-environment: &common-environment
SESSION_TIMEOUT_WARNING: "true"
RAILS_MAX_THREADS: "1"
REDIS_URL: "redis://redis"
- BUNDLER_RAILS_VERSION: "~> 7"
+ BUNDLER_RAILS_VERSION: "~> 8"
services:
mysql:
diff --git a/docs/api_reference.md b/docs/api_reference.md
new file mode 100644
index 0000000..82ecb2f
--- /dev/null
+++ b/docs/api_reference.md
@@ -0,0 +1,2628 @@
+# CommandTower API Reference
+
+## Table of Contents
+1. [High-Level Overview](#high-level-overview)
+2. [Authentication & Token Management](#authentication--token-management)
+3. [Request/Response Format](#requestresponse-format)
+4. [Authentication Endpoints](#authentication-endpoints)
+5. [User Management Endpoints](#user-management-endpoints)
+6. [Username Availability](#username-availability)
+7. [Roles and Authorization (RBAC)](#roles-and-authorization-rbac)
+8. [Admin Endpoints](#admin-endpoints)
+9. [Inbox Endpoints](#inbox-endpoints)
+10. [Complete Workflows](#complete-workflows)
+11. [Error Handling](#error-handling)
+
+**Note**: All authentication token extraction and management is handled by the centralized `CommandTower::Jwt::AuthorizationHelper` module. This ensures consistent behavior across all endpoints and abstracts header/cookie access logic.
+
+---
+
+## High-Level Overview
+
+CommandTower is an API-only Rails engine that provides:
+- **Authentication**: JWT-based bearer token authentication
+- **Authorization**: RBAC (Role-Based Access Control) with configurable roles
+- **User Management**: User registration, login, profile management
+- **Admin Functions**: User administration and role management
+- **Inbox System**: Messaging and message blast functionality
+
+### Base URL
+All endpoints are mounted under the CommandTower engine route (configured during initialization via the Rails generator).
+
+### Content Type
+All requests should use `Content-Type: application/json` header.
+
+All responses are returned as `Content-Type: application/json`.
+
+---
+
+## Authentication & Token Management
+
+CommandTower supports two authentication methods:
+1. **Header-based authentication** (default): `Authorization: Bearer {token}` header
+2. **Cookie-based authentication** (optional): HttpOnly cookies for web applications
+
+**📖 For web applications using cookie authentication**, see the comprehensive [Cookie Authentication Guide](cookie_authentication_guide.md) for setup instructions, CORS configuration, and best practices.
+
+### Authentication Header Format
+
+For all authenticated endpoints, include the JWT token in the `Authorization` header:
+
+```
+Authorization: Bearer {token_value}
+```
+
+**Critical Format Requirements**:
+- The header value MUST be in the format: `Bearer {token}` (with a space between "Bearer" and the token)
+- The word "Bearer" is case-sensitive
+- Missing or malformed headers will result in `401 Unauthorized` responses
+- Malformed headers return: `"Invalid Bearer token format"`
+- Missing headers return: `"Bearer token missing"`
+
+### Cookie Authentication (Optional)
+
+CommandTower supports HttpOnly cookie-based authentication for web applications. When enabled:
+
+**Configuration**:
+```ruby
+CommandTower.configure do |config|
+ config.jwt.cookie.enabled = true
+ # Optional: customize cookie settings
+ # config.jwt.cookie.name = "ct_jwt" # Default: "ct_jwt"
+ # config.jwt.cookie.same_site = :lax # Default: :lax (:lax, :strict, or :none)
+ # config.jwt.cookie.secure = true # Default: false (auto-set to true in production)
+ # config.jwt.cookie.path = "/" # Default: "/"
+ # config.jwt.cookie.domain = nil # Default: nil (host-only)
+ # config.jwt.cookie.ttl = 7.days # Default: matches JWT TTL
+end
+```
+
+**Authentication Priority**:
+1. **Authorization Header** (checked first): `Authorization: Bearer {token}`
+2. **HttpOnly Cookie** (fallback): Automatically used if header is missing and cookie auth is enabled
+
+**Behavior**:
+- On successful login, token is set in both `X-Authorization-Reset` header and HttpOnly cookie (if enabled)
+- When tokens are refreshed (via `X-Authorization-Reset: true`), the cookie is automatically updated
+- Use `POST /auth/logout` to clear the cookie (browser-only logout)
+
+**Security**:
+- Cookies are HttpOnly by default (not accessible via JavaScript)
+- Cookies use SameSite=Lax by default to prevent CSRF attacks
+- Optional double-submit CSRF protection can be enabled for additional security
+- Secure flag is automatically set to `true` in production environments
+- Cookie TTL matches the JWT TTL configuration
+
+**CSRF Protection**:
+- When CSRF protection is enabled, clients must send CSRF token in `X-CSRF-Token` header for unsafe methods (POST, PUT, PATCH, DELETE)
+- CSRF protection only applies to cookie-authenticated requests
+- Authorization header authentication is always exempt from CSRF checks
+- See [Cookie Authentication Guide](cookie_authentication_guide.md) for complete CSRF setup instructions
+
+**Example**:
+```
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInZlcmlmaWVyX3Rva2VuIjoiYWJjMTIzIiwiZ2VuZXJhdGVkX2F0IjoxNzA1NDQwMDAwfQ.signature
+```
+
+### Token Expiration Monitoring
+
+Every authenticated request returns a header indicating when the current token will expire:
+
+**Response Header**:
+```
+X-Authorization-Expire: "2025-01-16 04:36:29 +0000"
+```
+
+**Client Implementation**:
+1. Parse the timestamp from `X-Authorization-Expire` header on each response
+2. Monitor the expiration time
+3. Refresh the token before it expires to maintain session continuity
+4. Recommended: Refresh when less than 5 minutes remain before expiration
+
+### Token Refresh Mechanism
+
+**How to Refresh a Token in the Same Request**
+
+To refresh a JWT token on any authenticated request, include the following header:
+
+**Request Header**:
+```
+X-Authorization-Reset: true
+```
+
+**Response Header** (when refresh is requested):
+```
+X-Authorization-Reset: "{new_token_value}"
+```
+
+**Complete Example**:
+
+**Request**:
+```
+POST /user/modify
+Authorization: Bearer {old_token}
+X-Authorization-Reset: true
+Content-Type: application/json
+
+{
+ "first_name": "John"
+}
+```
+
+**Response**:
+```
+HTTP/1.1 201 Created
+X-Authorization-Expire: "2025-01-16 05:36:29 +0000"
+X-Authorization-Reset: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.{new_token_payload}.{new_signature}"
+Content-Type: application/json
+
+{
+ "id": 123,
+ "first_name": "John",
+ ...
+}
+```
+
+**Client Implementation Steps**:
+1. Include `X-Authorization-Reset: true` in any authenticated request when you want to refresh the token
+2. Check the response headers for `X-Authorization-Reset`
+3. If present, extract the new token value
+4. Replace your stored token with the new value
+5. Use the new token for all subsequent requests
+
+**Important Notes**:
+- Token refresh adds minimal latency but should be used judiciously
+- Only refresh when necessary (e.g., before expiration). After `POST /auth/password/change`, do not refresh — Sign In again.
+- The old token remains valid until it expires, but using the new token is recommended
+- You can refresh on any authenticated endpoint - no special refresh endpoint is needed
+
+### Token Structure
+
+JWT tokens contain the following encrypted payload:
+- `user_id` (Integer): The authenticated user's ID
+- `verifier_token` (String): A token that must match the user's current verifier_token
+- `generated_at` (Integer/Timestamp): Unix timestamp when the token was created
+
+**Token Validation**:
+- If a token's `verifier_token` doesn't match the user's current `verifier_token`, authentication fails with `401 Unauthorized`
+- This allows for "logout all sessions" functionality by resetting the user's verifier_token
+- Tokens expire based on the JWT TTL configuration (typically 24 hours)
+
+### Security Best Practices
+
+1. **Token Storage**: Store tokens securely (e.g., secure HTTP-only cookies, secure storage on mobile)
+2. **Token Transmission**: Always use HTTPS in production
+3. **Token Expiration**: Monitor and refresh tokens before expiration
+4. **Token Invalidation**: Use verifier_token reset to invalidate all sessions when needed
+5. **Never Log Tokens**: Avoid logging tokens in client-side code or server logs
+
+---
+
+## Request/Response Format
+
+### Request Format
+
+**Headers**:
+- `Content-Type: application/json` (required for POST/PATCH requests with body)
+- `Authorization: Bearer {token}` (required for authenticated endpoints)
+
+**Body** (for POST/PATCH requests):
+- JSON object with required/optional fields as specified per endpoint
+
+### Response Format
+
+**Success Responses**:
+- Status codes: `200 OK`, `201 Created`
+- Body: JSON object matching the endpoint's response schema
+
+**Error Responses**:
+- Status codes: `400 Bad Request`, `401 Unauthorized`, `403 Forbidden`, `500 Internal Server Error`
+- Body: Error schema (see [Error Handling](#error-handling) section)
+
+**Response Headers**:
+- `X-Authorization-Expire`: Token expiration timestamp (all authenticated requests)
+- `X-Authorization-Reset`: New token value (only when refresh requested)
+- `Set-Cookie`: HttpOnly cookie with JWT token (only when cookie authentication is enabled and token is set/refreshed)
+
+### Boolean Parameter Format
+
+Boolean values in request bodies can be sent in multiple formats:
+- `true` / `false` (JSON boolean)
+- `"true"` / `"false"` (string)
+- `1` / `0` (integer)
+- `"1"` / `"0"` (string)
+
+All formats are accepted and converted appropriately.
+
+---
+
+## Authentication Endpoints
+
+### 1. User Signup (Create Account)
+
+**Endpoint**: `POST /auth/create`
+
+**Authentication Required**: No
+
+**Request Headers**:
+```
+Content-Type: application/json
+```
+
+**Request Body Schema**:
+```json
+{
+ "first_name": "string (optional)",
+ "last_name": "string (optional)",
+ "username": "string (optional)",
+ "email": "string (optional)",
+ "password": "string (optional)",
+ "password_confirmation": "string (optional)"
+}
+```
+
+**Field Types**:
+- `first_name`: String (optional)
+- `last_name`: String (optional)
+- `username`: String (optional)
+- `email`: String (optional)
+- `password`: String (optional)
+- `password_confirmation`: String (optional)
+
+**Response** (201 Created):
+```json
+{
+ "full_name": "string",
+ "first_name": "string",
+ "last_name": "string",
+ "username": "string",
+ "email": "string",
+ "msg": "string"
+}
+```
+
+**Response Schema**:
+- `full_name`: String (required)
+- `first_name`: String (required)
+- `last_name`: String (required)
+- `username`: String (required)
+- `email`: String (required)
+- `msg`: String (required)
+
+**Error Responses**:
+- `400 Bad Request`: Invalid arguments (validation errors in `invalid_arguments` object)
+
+**Notes**:
+- This endpoint creates a new user account
+- After signup, the user should log in to receive a JWT token
+- If email verification is enabled, the user will need to verify their email before accessing protected endpoints
+- Password and password_confirmation must match
+
+---
+
+### 2. User Login
+
+**Endpoint**: `POST /auth/login`
+
+**Authentication Required**: No
+
+**Request Headers**:
+```
+Content-Type: application/json
+```
+
+**Request Body Schema**:
+```json
+{
+ "identifier": "string (optional, can be username or email)",
+ "password": "string (optional)"
+}
+```
+
+**Field Types**:
+- `identifier`: String (optional, can be username or email)
+- `password`: String (optional)
+
+**Response** (201 Created):
+```json
+{
+ "token": "string",
+ "header_name": "string",
+ "message": "string",
+ "user": {
+ "id": "integer",
+ "username": "string",
+ "email": "string",
+ "first_name": "string",
+ "last_name": "string",
+ "email_validated": "boolean",
+ "roles": "array",
+ "created_at": "string",
+ "verifier_token": "string",
+ "last_known_timezone": "string"
+ }
+}
+```
+
+**Response Schema**:
+- `token`: String (required) - JWT token to use in Authorization header
+- `header_name`: String (required) - Always "Authorization"
+- `message`: String (required) - Success message
+- `user`: Object (required) - User object with configured default attributes:
+ - `id`: Integer (required)
+ - `username`: String (required)
+ - `email`: String (required)
+ - `first_name`: String (optional, based on config)
+ - `last_name`: String (optional, based on config)
+ - `email_validated`: Boolean (optional, based on config)
+ - `roles`: Array (optional, based on config)
+ - `created_at`: String/Timestamp (optional, based on config)
+ - `verifier_token`: String (optional, based on config)
+ - `last_known_timezone`: String (optional, based on config)
+ - Additional attributes may be included based on configuration
+
+**Error Responses**:
+- `400 Bad Request`: Invalid request format
+- `401 Unauthorized`: Invalid credentials or invalid arguments
+
+**Response Headers** (when cookie authentication is enabled):
+- `X-Authorization-Reset`: JWT token value
+- `X-Authorization-Expire`: Token expiration timestamp
+- `Set-Cookie`: HttpOnly cookie containing the JWT token
+
+**Notes**:
+- Provide `identifier` which can be either username or email (the system will check both fields)
+- The returned `token` should be used in the `Authorization: Bearer {token}` header for subsequent requests
+- If cookie authentication is enabled, the token is also automatically set as an HttpOnly cookie
+- If email verification is enabled and the user hasn't verified their email, they may be restricted from certain endpoints
+- User object attributes are configurable via `CommandTower.config.user.default_attributes`
+
+---
+
+### 3. User Logout
+
+**Endpoint**: `POST /auth/logout`
+
+**Authentication Required**: No (but cookie will only be cleared if present)
+
+**Request Headers**:
+```
+Content-Type: application/json
+```
+
+**Request Body**: None
+
+**Response** (200 OK):
+```json
+{
+ "message": "Logged out"
+}
+```
+
+**Response Schema**:
+- `message`: String (required) - "Logged out"
+
+**Error Responses**: None (always returns 200)
+
+**Response Headers**:
+- `Set-Cookie`: Cookie is cleared (expired in the past) if cookie authentication is enabled
+
+**Notes**:
+- Clears the HttpOnly JWT cookie if cookie authentication is enabled
+- This is a browser-only logout that does NOT reset the user's `verifier_token`
+- To log out of all sessions (including mobile/API clients), use `POST /user/modify` with `verifier_token: true` instead
+- If cookie authentication is disabled, this endpoint still returns success but performs no action
+- Useful for web applications where you want to clear the browser session without invalidating tokens used by mobile apps
+
+---
+
+### 4. Email Verification - Send Code
+
+**Endpoint**: `POST /auth/email/send`
+
+**Authentication Required**: Yes (Bearer token)
+
+**Request Headers**:
+```
+Authorization: Bearer {token}
+Content-Type: application/json
+```
+
+**Request Body**: None
+
+**Response** (201 Created):
+```json
+{
+ "message": "string"
+}
+```
+
+**Response Schema**:
+- `message`: String (required) - "Successfully sent Email verification code"
+
+**Response** (200 OK - if already verified):
+```json
+{
+ "message": "string"
+}
+```
+
+**Response Schema**:
+- `message`: String (required) - "Email is already verified. No code required"
+
+**Error Responses**:
+- `401 Unauthorized`: Missing or invalid token
+
+**Response Headers**:
+- `X-Authorization-Expire`: Token expiration timestamp
+
+**Notes**:
+- Sends a verification code to the user's email
+- The code is stored in the system and can be verified using the verify endpoint
+- If email verification is disabled in configuration, this endpoint may not be available
+- User must be authenticated to request a verification code
+
+---
+
+### 5. Email Verification - Verify Code
+
+**Endpoint**: `POST /auth/email/verify`
+
+**Authentication Required**: Yes (Bearer token)
+
+**Request Headers**:
+```
+Authorization: Bearer {token}
+Content-Type: application/json
+```
+
+**Request Body Schema**:
+```json
+{
+ "code": "string"
+}
+```
+
+**Field Types**:
+- `code`: String (required) - Verification code sent to user's email
+
+**Response** (201 Created):
+```json
+{
+ "message": "string"
+}
+```
+
+**Response Schema**:
+- `message`: String (required) - "Successfully verified email"
+
+**Response** (200 OK - if already verified):
+```json
+{
+ "message": "string"
+}
+```
+
+**Response Schema**:
+- `message`: String (required) - "Email is already verified."
+
+**Error Responses**:
+- `401 Unauthorized`: Missing or invalid token
+- `403 Forbidden`: Invalid verification code or other validation error
+- `400 Bad Request`: Invalid arguments
+
+**Response Headers**:
+- `X-Authorization-Expire`: Token expiration timestamp
+
+**Notes**:
+- The code is typically a string sent to the user's email
+- After successful verification, the user's `email_validated` flag is set to true
+- Once verified, users can access all protected endpoints (if email verification is required)
+- Invalid codes will result in 403 Forbidden
+
+---
+
+### 6. Password Reset - Send Email
+
+**Endpoint**: `POST /auth/password/forgot/send`
+
+**Authentication Required**: No
+
+**Request Headers**:
+```
+Content-Type: application/json
+```
+
+**Request Body Schema**:
+```json
+{
+ "email": "string"
+}
+```
+
+**Field Types**:
+- `email`: String (required) - User's email address
+
+**Response** (200 OK):
+```json
+{
+ "message": "string"
+}
+```
+
+**Response Schema**:
+- `message`: String (required) - "If an account exists with that email, a password reset link has been sent."
+
+**Error Responses**:
+- `400 Bad Request`: Invalid email format or missing email
+
+**Notes**:
+- Always returns 200 OK, even if email doesn't exist (prevents user enumeration)
+- Never returns 500, even if email delivery fails (security best practice)
+- Sends password reset email with token if user exists
+- Token expires after configured time (default: 1 hour)
+- If password reset is disabled in configuration, this endpoint may not be available
+
+---
+
+### 7. Password Reset - Validate Token
+
+**Endpoint**: `POST /auth/password/forgot/validate`
+
+**Authentication Required**: No
+
+**Request Headers**:
+```
+Content-Type: application/json
+```
+
+**Request Body Schema**:
+```json
+{
+ "token": "string"
+}
+```
+
+**Field Types**:
+- `token`: String (required) - Password reset token from email
+
+**Response** (200 OK):
+```json
+{
+ "valid": true,
+ "expires_at": "string"
+}
+```
+
+**Response Schema**:
+- `valid`: Boolean (required) - Whether token is valid
+- `expires_at`: String (optional) - Token expiration timestamp
+
+**Error Responses**:
+- `400 Bad Request`: Missing token
+- `401 Unauthorized`: Invalid, expired, or used token (generic "Invalid token" message)
+
+**Notes**:
+- Validates password reset token before showing reset form
+- Returns generic error message (doesn't reveal if token is expired, used, or not found)
+- If password reset is disabled in configuration, this endpoint may not be available
+
+---
+
+### 8. Password Reset - Reset Password
+
+**Endpoint**: `POST /auth/password/forgot/reset`
+
+**Authentication Required**: No
+
+**Request Headers**:
+```
+Content-Type: application/json
+```
+
+**Request Body Schema**:
+```json
+{
+ "token": "string",
+ "password": "string",
+ "password_confirmation": "string"
+}
+```
+
+**Field Types**:
+- `token`: String (required) - Password reset token from email
+- `password`: String (required) - New password
+- `password_confirmation`: String (required) - Password confirmation (must match password)
+
+**Response** (200 OK):
+```json
+{
+ "message": "string"
+}
+```
+
+**Response Schema**:
+- `message`: String (required) - "Password has been successfully reset"
+
+**Error Responses**:
+- `400 Bad Request`: Missing token, password validation errors, password mismatch
+- `401 Unauthorized`: Invalid, expired, or used token (generic "Invalid token" message)
+
+**Notes**:
+- Resets user password using token from email
+- Token is single-use and invalidated after successful reset
+- Returns generic error message for invalid tokens (doesn't reveal specific reason)
+- Password must meet length requirements (configurable)
+- If password reset is disabled in configuration, this endpoint may not be available
+
+---
+
+### 8b. Authenticated Password Change
+
+**Endpoint**: `POST /auth/password/change`
+
+**Authentication Required**: Yes (Bearer token)
+
+**Request Headers**:
+```
+Authorization: Bearer {token}
+Content-Type: application/json
+```
+
+**Request Body Schema**:
+```json
+{
+ "current_password": "string",
+ "password": "string",
+ "password_confirmation": "string"
+}
+```
+
+**Field Types**:
+- `current_password`: String (required) - Caller's current password
+- `password`: String (required) - New password
+- `password_confirmation`: String (required) - Must match `password`
+
+**Response** (200 OK):
+```json
+{
+ "message": "Password has been successfully changed"
+}
+```
+
+**Response Schema**:
+- `message`: String (required)
+
+**Error Responses**:
+- `400 Bad Request`: Incorrect current password, confirmation mismatch, length / model validation errors
+- `401 Unauthorized`: Missing or invalid JWT
+- `500 Internal Server Error`: Failure to rotate session verifier (transaction rolled back)
+
+**Notes**:
+- Requires plain_text login enabled (`CommandTower.config.login.plain_text.enable?`)
+- On success, rotates `verifier_token` in the same transaction as the password update — **all** existing sessions are invalidated, including the caller's
+- Does **not** re-issue a JWT; the host must clear local auth and send the user through Sign In
+- Distinct from public `POST /auth/password/forgot/reset` (token-based recovery; does not rotate verifier)
+- See [change_password_workflow.md](change_password_workflow.md)
+
+---
+
+## User Management Endpoints
+
+### 9. Get Current User
+
+**Endpoint**: `GET /user`
+
+**Authentication Required**: Yes (Bearer token)
+
+**Request Headers**:
+```
+Authorization: Bearer {token}
+```
+
+**Request Body**: None
+
+**Response** (200 OK):
+```json
+{
+ "id": "integer",
+ "username": "string",
+ "email": "string",
+ "first_name": "string",
+ "last_name": "string",
+ "email_validated": "boolean",
+ "roles": "array",
+ "created_at": "string",
+ "verifier_token": "string",
+ "last_known_timezone": "string"
+}
+```
+
+**Response Schema**:
+- User object with configured default attributes (same structure as login response user object)
+- Attributes included are based on `CommandTower.config.user.default_attributes` configuration
+
+**Error Responses**:
+- `401 Unauthorized`: Missing or invalid token
+- If email verification is required and not completed, may return 412
+
+**Response Headers**:
+- `X-Authorization-Expire`: Token expiration timestamp
+
+**Notes**:
+- Returns the currently authenticated user's information
+- User object attributes are configurable
+
+---
+
+### 10. Modify Current User
+
+**Endpoint**: `POST /user/modify`
+
+**Authentication Required**: Yes (Bearer token)
+
+**Request Headers**:
+```
+Authorization: Bearer {token}
+Content-Type: application/json
+```
+
+**Request Body Schema**:
+```json
+{
+ "email": "string (optional)",
+ "email_validated": "boolean (optional)",
+ "first_name": "string (optional)",
+ "last_name": "string (optional)",
+ "username": "string (optional)",
+ "verifier_token": "boolean (optional)"
+}
+```
+
+**Field Types**:
+- `email`: String (optional)
+- `email_validated`: Boolean (optional) - Accepts: true, false, "true", "false", 1, 0, "1", "0"
+- `first_name`: String (optional)
+- `last_name`: String (optional)
+- `username`: String (optional)
+- `verifier_token`: Boolean (optional) - Accepts: true, false, "true", "false", 1, 0, "1", "0"
+
+**Response** (201 Created):
+```json
+{
+ "id": "integer",
+ "username": "string",
+ "email": "string",
+ "first_name": "string",
+ "last_name": "string",
+ "email_validated": "boolean",
+ "roles": "array",
+ "created_at": "string",
+ "verifier_token": "string",
+ "last_known_timezone": "string"
+}
+```
+
+**Response Schema**:
+- Updated User object with configured default attributes
+
+**Error Responses**:
+- `400 Bad Request`: Invalid arguments (validation errors)
+- `401 Unauthorized`: Missing or invalid token
+- `500 Internal Server Error`: Server error
+
+**Response Headers**:
+- `X-Authorization-Expire`: Token expiration timestamp
+
+**Notes**:
+- Only include fields you want to update
+- Setting `verifier_token: true` will reset the verifier token, invalidating all existing sessions (logout all devices)
+- This is useful for security purposes (e.g., if account is compromised)
+
+---
+
+## Username Availability
+
+### 11. Check Username Availability
+
+**Endpoint**: `GET /username/available/:username`
+
+**Authentication Required**: No
+
+**URL Parameters**:
+- `username`: String (required) - The username to check (in URL path)
+
+**Request Headers**: None required
+
+**Request Body**: None
+
+**Response** (200 OK):
+```json
+{
+ "username": {
+ "available": "boolean",
+ "valid": "boolean",
+ "description": "string"
+ }
+}
+```
+
+**Response Schema**:
+- `username`: Object (required)
+ - `available`: Boolean (required) - Whether the username is available
+ - `valid`: Boolean (required) - Whether the username passes validation rules
+ - `description`: String (required) - Validation failure message if invalid, or success message
+
+**Error Responses**:
+- `400 Bad Request`: Invalid request parameters (e.g., missing or invalid username parameter)
+
+**Notes**:
+- This endpoint may only be available if realtime username checking is enabled in configuration
+- Use this endpoint before attempting user registration to check username availability
+- The endpoint checks both availability and validation rules
+
+---
+
+## Roles and Authorization (RBAC)
+
+### Overview
+
+CommandTower uses RBAC (Role-Based Access Control) to manage permissions. The system works by:
+1. **Entities**: Define which controllers and actions require authorization
+2. **Roles**: Group entities together and assign them to users
+3. **Authorization Check**: When a user makes a request, the system checks if their roles grant access to the requested controller/action
+
+### How Authorization Works
+
+**Authorization Flow**:
+1. User makes an authenticated request with a Bearer token
+2. System authenticates the user (validates token)
+3. System checks if the controller/action requires authorization
+4. If authorization is required, system checks user's roles
+5. System verifies if any of the user's roles grant access to the requested endpoint
+6. If authorized, request proceeds; if not, returns `403 Forbidden`
+
+**Key Concepts**:
+- **Entities**: Map controllers and specific actions to authorization requirements
+- **Roles**: Collections of entities that define what a user can access
+- **Multiple Roles**: Users can have multiple roles; access is the union of all role permissions
+- **Owner Role**: Special role that bypasses all authorization checks
+
+### CommandTower Endpoints by Authorization Requirement
+
+#### Quick Reference: Endpoint Access by Role
+
+| Endpoint | Public | Authenticated | owner | admin | admin-without-impersonation | admin-read-only |
+|----------|--------|---------------|-------|-------|----------------------------|-----------------|
+| `POST /auth/create` | ✓ | - | ✓ | ✓ | ✓ | ✓ |
+| `POST /auth/login` | ✓ | - | ✓ | ✓ | ✓ | ✓ |
+| `GET /username/available/:username` | ✓ | - | ✓ | ✓ | ✓ | ✓ |
+| `GET /user` | - | ✓ | ✓ | ✓ | ✓ | ✓ |
+| `POST /user/modify` | - | ✓ | ✓ | ✓ | ✓ | ✓ |
+| `GET /inbox/messages` | - | ✓ | ✓ | ✓ | ✓ | ✓ |
+| `GET /inbox/messages/:id` | - | ✓ | ✓ | ✓ | ✓ | ✓ |
+| `DELETE /inbox/messages/:id` | - | ✓ | ✓ | ✓ | ✓ | ✓ |
+| `POST /inbox/messages/ack` | - | ✓ | ✓ | ✓ | ✓ | ✓ |
+| `POST /inbox/messages/delete` | - | ✓ | ✓ | ✓ | ✓ | ✓ |
+| `POST /auth/email/send` | - | ✓ | ✓ | ✓ | ✓ | ✓ |
+| `POST /auth/email/verify` | - | ✓ | ✓ | ✓ | ✓ | ✓ |
+| `GET /admin` | - | ✓ | ✓ | ✓ | ✓ | ✓ |
+| `POST /admin/modify` | - | ✓ | ✓ | ✓ | ✓ | ✗ |
+| `POST /admin/modify/role` | - | ✓ | ✓ | ✓ | ✓ | ✗ |
+| `POST /admin/impersonate` | - | ✓ | ✓ | ✓ | ✗ | ✗ |
+| `GET /inbox/blast` | - | ✓ | ✓ | ✓ | ✓ | ✓ (metadata only) |
+| `GET /inbox/blast/:id` | - | ✓ | ✓ | ✓ | ✓ | ✗ |
+| `POST /inbox/blast` | - | ✓ | ✓ | ✓ | ✓ | ✗ |
+| `PATCH /inbox/blast/:id` | - | ✓ | ✓ | ✓ | ✓ | ✗ |
+| `DELETE /inbox/blast/:id` | - | ✓ | ✓ | ✓ | ✓ | ✗ |
+
+**Legend**:
+- ✓ = Access granted
+- ✗ = Access denied (403 Forbidden)
+- - = Not applicable
+
+#### Public Endpoints (No Authentication Required)
+- `POST /auth/create` - User signup
+- `POST /auth/login` - User login
+- `GET /username/available/:username` - Check username availability
+
+#### Authenticated Only (No Role Required)
+These endpoints require authentication but no specific role:
+- `GET /user` - Get current user
+- `POST /user/modify` - Modify current user
+- `GET /inbox/messages` - List messages
+- `GET /inbox/messages/:id` - Get single message
+- `DELETE /inbox/messages/:id` - Delete message
+- `POST /inbox/messages/ack` - Acknowledge messages
+- `POST /inbox/messages/delete` - Delete messages (bulk)
+- `POST /auth/email/send` - Send email verification
+- `POST /auth/email/verify` - Verify email
+
+#### Admin-Only Endpoints (Require Admin Roles)
+These endpoints require both authentication and appropriate admin roles:
+
+**AdminController Endpoints**:
+- `GET /admin` - List all users
+- `POST /admin/modify` - Modify any user
+- `POST /admin/modify/role` - Modify user roles
+- `POST /admin/impersonate` - Impersonate user (if implemented)
+
+**MessageBlastController Endpoints**:
+- `GET /inbox/blast` - List message blasts
+- `GET /inbox/blast/:id` - Get message blast
+- `POST /inbox/blast` - Create message blast
+- `PATCH /inbox/blast/:id` - Modify message blast
+- `DELETE /inbox/blast/:id` - Delete message blast
+
+### Default Roles
+
+#### `owner`
+- **Access**: Full access to ALL routes regardless of required roles
+- **Bypasses**: All authorization checks
+- **Use Case**: System owner/super admin
+- **Entities**: `true` (allows everything)
+
+#### `admin`
+- **Access**:
+ - All `AdminController` actions (including impersonate)
+ - All `MessageBlastController` actions
+- **Entities**:
+ - `admin` (AdminController, all actions)
+ - `message-blast` (MessageBlastController, all actions)
+- **Endpoints**:
+ - `GET /admin` ✓
+ - `POST /admin/modify` ✓
+ - `POST /admin/modify/role` ✓
+ - `POST /admin/impersonate` ✓ (if implemented)
+ - All message blast endpoints ✓
+
+#### `admin-without-impersonation`
+- **Access**:
+ - All `AdminController` actions EXCEPT impersonate
+ - All `MessageBlastController` actions
+- **Entities**:
+ - `admin-without-impersonate` (AdminController, except: impersonate)
+ - `message-blast` (MessageBlastController, all actions)
+- **Endpoints**:
+ - `GET /admin` ✓
+ - `POST /admin/modify` ✓
+ - `POST /admin/modify/role` ✓
+ - `POST /admin/impersonate` ✗ (blocked)
+ - All message blast endpoints ✓
+
+#### `admin-read-only`
+- **Access**:
+ - Read-only access to `AdminController` (show action only)
+ - Read-only access to `MessageBlastController` (metadata only)
+- **Entities**:
+ - `read-admin` (AdminController, only: show)
+ - `message-blast-read-only` (MessageBlastController, only: metadata)
+- **Endpoints**:
+ - `GET /admin` ✓
+ - `POST /admin/modify` ✗ (blocked)
+ - `POST /admin/modify/role` ✗ (blocked)
+ - `GET /inbox/blast` ✓ (metadata only)
+ - `GET /inbox/blast/:id` ✗ (blocked)
+ - `POST /inbox/blast` ✗ (blocked)
+ - `PATCH /inbox/blast/:id` ✗ (blocked)
+ - `DELETE /inbox/blast/:id` ✗ (blocked)
+
+### Role Assignment
+
+Roles are assigned to users via the admin endpoint:
+```json
+POST /admin/modify/role
+Authorization: Bearer {admin_token}
+Content-Type: application/json
+
+{
+ "user_id": 123,
+ "roles": ["admin"]
+}
+```
+
+**Multiple Roles**: Users can have multiple roles. If a user has multiple roles, they get access to the union of all permissions from those roles.
+
+**Example**:
+```json
+{
+ "user_id": 123,
+ "roles": ["admin", "moderator"]
+}
+```
+The user will have access to all endpoints granted by either `admin` or `moderator` roles.
+
+### Authorization Errors
+
+When a user attempts to access an endpoint without the required role:
+- **Status**: `403 Forbidden`
+- **Response**:
+```json
+{
+ "status": "403",
+ "message": "Unauthorized Access. Incorrect User Privileges"
+}
+```
+
+### Using RBAC with Custom Endpoints
+
+CommandTower's RBAC system can be extended to protect your own custom controllers and endpoints outside of CommandTower. This allows you to use the same authorization system for your application's endpoints.
+
+#### Step 1: Create Your Custom Controller
+
+First, create your controller that inherits from `CommandTower::ApplicationController`:
+
+```ruby
+# app/controllers/api/v1/products_controller.rb
+module Api
+ module V1
+ class ProductsController < CommandTower::ApplicationController
+ include CommandTower::SchemaHelper
+
+ before_action :authenticate_user!
+ before_action :authorize_user! # Add this to require authorization
+
+ def index
+ # List products
+ end
+
+ def show
+ # Show single product
+ end
+
+ def create
+ # Create product
+ end
+
+ def update
+ # Update product
+ end
+
+ def destroy
+ # Delete product
+ end
+ end
+ end
+end
+```
+
+#### Step 2: Define Entities
+
+Entities map controllers to authorization requirements. Create a YAML configuration file at `config/rbac_groups.yml`:
+
+```yaml
+entities:
+ # Define entities for your custom controllers
+ - name: products-read
+ controller: Api::V1::ProductsController
+ only: [index, show] # Only allow index and show actions
+
+ - name: products-write
+ controller: Api::V1::ProductsController
+ only: [create, update, destroy] # Only allow create, update, destroy
+
+ - name: products-full
+ controller: Api::V1::ProductsController
+ # No 'only' or 'except' means all actions are included
+
+ # Example: Exclude specific actions
+ - name: products-no-delete
+ controller: Api::V1::ProductsController
+ except: [destroy] # All actions except destroy
+```
+
+**Entity Options**:
+- `name`: Unique identifier for the entity
+- `controller`: Controller class name (string or constant)
+- `only`: Array of action names (only these actions require authorization)
+- `except`: Array of action names (all actions except these require authorization)
+- If neither `only` nor `except` is specified, all controller actions require authorization
+
+#### Step 3: Define Roles
+
+Roles group entities together. Add roles to the same `config/rbac_groups.yml` file:
+
+```yaml
+groups:
+ # Product Manager Role - Full access to products
+ product-manager:
+ description: "Full access to product management"
+ entities:
+ - products-full
+
+ # Product Viewer Role - Read-only access
+ product-viewer:
+ description: "Read-only access to products"
+ entities:
+ - products-read
+
+ # Product Editor Role - Can create/update but not delete
+ product-editor:
+ description: "Can create and update products but not delete"
+ entities:
+ - products-read
+ - products-write
+
+ # Example: Role with multiple entities
+ content-manager:
+ description: "Manages both products and articles"
+ entities:
+ - products-full
+ - articles-full
+```
+
+**Role Options**:
+- `description`: Human-readable description of the role
+- `entities`: Array of entity names that this role grants access to
+- `entities: true`: Special value that grants access to ALL endpoints (like `owner` role)
+
+#### Step 4: Assign Roles to Users
+
+Use the admin endpoint to assign roles:
+
+```json
+POST /admin/modify/role
+Authorization: Bearer {admin_token}
+Content-Type: application/json
+
+{
+ "user_id": 123,
+ "roles": ["product-manager"]
+}
+```
+
+#### Complete Example: Products API
+
+**1. Controller** (`app/controllers/api/v1/products_controller.rb`):
+```ruby
+module Api
+ module V1
+ class ProductsController < CommandTower::ApplicationController
+ before_action :authenticate_user!
+ before_action :authorize_user!
+
+ def index
+ # Only users with products-read or products-full can access
+ products = Product.all
+ render json: products
+ end
+
+ def create
+ # Only users with products-write or products-full can access
+ product = Product.create(product_params)
+ render json: product, status: :created
+ end
+ end
+ end
+end
+```
+
+**2. RBAC Configuration** (`config/rbac_groups.yml`):
+```yaml
+entities:
+ - name: products-read
+ controller: Api::V1::ProductsController
+ only: [index, show]
+
+ - name: products-write
+ controller: Api::V1::ProductsController
+ only: [create, update, destroy]
+
+ - name: products-full
+ controller: Api::V1::ProductsController
+
+groups:
+ product-manager:
+ description: "Full product management access"
+ entities:
+ - products-full
+
+ product-viewer:
+ description: "Read-only product access"
+ entities:
+ - products-read
+
+ product-editor:
+ description: "Can create and update products"
+ entities:
+ - products-read
+ - products-write
+```
+
+**3. Assign Role**:
+```json
+POST /admin/modify/role
+{
+ "user_id": 456,
+ "roles": ["product-manager"]
+}
+```
+
+**4. Access Control**:
+- User with `product-manager` role: Can access all product endpoints
+- User with `product-viewer` role: Can only access `index` and `show`
+- User with `product-editor` role: Can access `index`, `show`, `create`, `update`, `destroy` (but not delete if you create a separate entity for that)
+
+#### Advanced: Custom Entity Authorization
+
+For more complex authorization logic, you can create custom entity classes that override the `authorized?` method:
+
+```ruby
+# app/models/authorization/product_entity.rb
+class ProductEntity < CommandTower::Authorization::Entity
+ def authorized?(user:)
+ # Custom logic: Only allow if user's email is validated
+ return false unless user.email_validated
+
+ # Additional custom checks
+ user.active? && user.organization_id == product.organization_id
+ end
+end
+```
+
+Then use it in your YAML:
+```yaml
+entities:
+ - name: products-custom
+ controller: Api::V1::ProductsController
+ entity_class: ProductEntity # Use custom entity class
+```
+
+#### Configuration
+
+The RBAC configuration file path can be customized in your CommandTower initializer:
+
+```ruby
+# config/initializers/command_tower.rb
+CommandTower.configure do |c|
+ c.authorization.rbac_group_path = Rails.root.join("config", "custom_rbac.yml")
+end
+```
+
+**Default Path**: `config/rbac_groups.yml`
+
+#### Best Practices
+
+1. **Use Descriptive Names**: Entity and role names should clearly indicate their purpose
+2. **Principle of Least Privilege**: Grant minimum necessary permissions
+3. **Group Related Actions**: Use `only` or `except` to group related actions
+4. **Document Roles**: Use clear descriptions in your YAML configuration
+5. **Test Authorization**: Always test that authorization works as expected
+6. **Multiple Roles**: Users can have multiple roles; design roles to work well together
+
+#### Troubleshooting
+
+**Authorization Not Working**:
+- Ensure `before_action :authorize_user!` is called after `authenticate_user!`
+- Verify the controller class name in YAML matches exactly (including namespace)
+- Check that entity names in roles match entity names in entities section
+- Ensure roles are assigned to users via `/admin/modify/role`
+
+**Controller Not Found Error**:
+- Ensure controller is loaded before RBAC configuration is loaded
+- Verify controller class name is correct (use full namespace: `Api::V1::ProductsController`)
+
+**Actions Not Protected**:
+- Verify entity definition includes the action in `only` or excludes it in `except`
+- Check that `before_action :authorize_user!` is present in controller
+
+---
+
+## Admin Endpoints
+
+All admin endpoints require:
+1. **Authentication**: Valid Bearer token
+2. **Authorization**: User must have appropriate admin role
+
+**Default Admin Roles**:
+- `owner`: Full access to all routes (bypasses all authorization checks)
+- `admin`: Full access to all AdminController actions including impersonation
+- `admin-without-impersonation`: Full admin access except impersonation
+- `admin-read-only`: Can only view users (GET /admin)
+
+**Authorization Failure**: Returns `403 Forbidden` if user lacks required permissions.
+
+---
+
+### 12. List All Users (Admin)
+
+**Endpoint**: `GET /admin`
+
+**Authentication Required**: Yes (Bearer token with admin role)
+
+**Request Headers**:
+```
+Authorization: Bearer {token}
+```
+
+**Query Parameters** (Pagination - Optional):
+- `pagination=true` (String, required to enable pagination)
+- `limit=` (Integer, optional, defaults to configured default, typically 10)
+- `page=` (Integer, optional, page number starting from 1)
+- `cursor=` (Integer, optional, takes precedence over page)
+
+**Request Body**: None
+
+**Response** (200 OK):
+```json
+{
+ "users": [
+ {
+ "id": "integer",
+ "username": "string",
+ "email": "string",
+ "first_name": "string",
+ "last_name": "string",
+ "email_validated": "boolean",
+ "roles": "array",
+ "created_at": "string",
+ "verifier_token": "string",
+ "last_known_timezone": "string"
+ }
+ ],
+ "count": "integer",
+ "pagination": {
+ "current": {
+ "cursor": "integer",
+ "limit": "integer",
+ "query": "string"
+ },
+ "next": {
+ "cursor": "integer",
+ "limit": "integer",
+ "query": "string"
+ },
+ "count_available": "integer",
+ "current_page": "integer",
+ "remaining_pages": "integer",
+ "total_pages": "integer"
+ }
+}
+```
+
+**Response Schema**:
+- `users`: Array (required) - Array of User objects with configured default attributes
+- `count`: Integer (optional) - Total count of users
+- `pagination`: Object (optional) - Pagination metadata:
+ - `current`: Object (required) - Current page information:
+ - `cursor`: Integer (required) - Current cursor position
+ - `limit`: Integer (required) - Current page limit
+ - `query`: String (required) - Query identifier
+ - `next`: Object (optional) - Next page information (same structure as current)
+ - `count_available`: Integer (optional) - Number of records available
+ - `current_page`: Integer (optional) - Current page number
+ - `remaining_pages`: Integer (optional) - Number of pages remaining
+ - `total_pages`: Integer (optional) - Total number of pages
+
+**Error Responses**:
+- `401 Unauthorized`: Missing or invalid token
+- `403 Forbidden`: User does not have admin permissions
+
+**Response Headers**:
+- `X-Authorization-Expire`: Token expiration timestamp
+
+**Notes**:
+- Pagination is optional but recommended for large user lists
+- Default pagination limit is typically 10 (configurable)
+- Use `cursor` for cursor-based pagination (recommended for large datasets)
+- Use `page` for page-based pagination
+- `cursor` takes precedence over `page` if both are provided
+- Example: `GET /admin?pagination=true&limit=50&page=1`
+
+---
+
+### 13. Modify User (Admin)
+
+**Endpoint**: `POST /admin/modify`
+
+**Authentication Required**: Yes (Bearer token with admin role)
+
+**Request Headers**:
+```
+Authorization: Bearer {token}
+Content-Type: application/json
+```
+
+**Request Body Schema**:
+```json
+{
+ "user_id": "integer",
+ "email": "string (optional)",
+ "email_validated": "boolean (optional)",
+ "first_name": "string (optional)",
+ "last_name": "string (optional)",
+ "username": "string (optional)",
+ "verifier_token": "boolean (optional)"
+}
+```
+
+**Field Types**:
+- `user_id`: Integer (required) - ID of the user to modify
+- `email`: String (optional)
+- `email_validated`: Boolean (optional) - Accepts: true, false, "true", "false", 1, 0, "1", "0"
+- `first_name`: String (optional)
+- `last_name`: String (optional)
+- `username`: String (optional)
+- `verifier_token`: Boolean (optional) - Accepts: true, false, "true", "false", 1, 0, "1", "0"
+
+**Response** (201 Created):
+```json
+{
+ "id": "integer",
+ "username": "string",
+ "email": "string",
+ "first_name": "string",
+ "last_name": "string",
+ "email_validated": "boolean",
+ "roles": "array",
+ "created_at": "string",
+ "verifier_token": "string",
+ "last_known_timezone": "string"
+}
+```
+
+**Response Schema**:
+- Updated User object with configured default attributes
+
+**Error Responses**:
+- `400 Bad Request`: Invalid arguments (validation errors, including invalid user_id)
+- `401 Unauthorized`: Missing or invalid token
+- `403 Forbidden`: User does not have admin permissions
+- `500 Internal Server Error`: Server error
+
+**Response Headers**:
+- `X-Authorization-Expire`: Token expiration timestamp
+
+**Notes**:
+- Admins can modify any user's attributes
+- Setting `verifier_token: true` will reset the user's verifier token, logging them out of all sessions
+- This is useful for security purposes (e.g., if user's account is compromised)
+
+---
+
+### 14. Modify User Roles (Admin)
+
+**Endpoint**: `POST /admin/modify/role`
+
+**Authentication Required**: Yes (Bearer token with admin role)
+
+**Request Headers**:
+```
+Authorization: Bearer {token}
+Content-Type: application/json
+```
+
+**Request Body Schema**:
+```json
+{
+ "user_id": "integer",
+ "roles": ["array", "of", "role", "strings"]
+}
+```
+
+**Field Types**:
+- `user_id`: Integer (required) - ID of the user to modify
+- `roles`: Array (optional, defaults to empty array) - Array of role strings
+
+**Response** (201 Created):
+```json
+{
+ "id": "integer",
+ "username": "string",
+ "email": "string",
+ "first_name": "string",
+ "last_name": "string",
+ "email_validated": "boolean",
+ "roles": "array",
+ "created_at": "string",
+ "verifier_token": "string",
+ "last_known_timezone": "string"
+}
+```
+
+**Response Schema**:
+- Updated User object with configured default attributes including roles
+
+**Error Responses**:
+- `400 Bad Request`: Invalid arguments (validation errors, including invalid user_id)
+- `401 Unauthorized`: Missing or invalid token
+- `403 Forbidden`: User does not have admin permissions
+- `500 Internal Server Error`: Server error
+
+**Response Headers**:
+- `X-Authorization-Expire`: Token expiration timestamp
+
+**Notes**:
+- Admins can assign roles to users
+- Roles control access to various endpoints
+- Valid roles are defined in the RBAC configuration
+- Passing an empty array or omitting `roles` will remove all roles from the user
+- Example roles: `["admin"]`, `["admin", "moderator"]`, `[]` (removes all roles)
+
+---
+
+## Inbox Endpoints
+
+All inbox endpoints require authentication (Bearer token).
+
+---
+
+### 15. Get Messages Metadata
+
+**Endpoint**: `GET /inbox/messages`
+
+**Authentication Required**: Yes (Bearer token)
+
+**Request Headers**:
+```
+Authorization: Bearer {token}
+```
+
+**Query Parameters** (Pagination - Optional):
+- `pagination=true` (String, required to enable pagination)
+- `limit=` (Integer, optional)
+- `page=` (Integer, optional)
+- `cursor=` (Integer, optional, takes precedence over page)
+
+**Request Body**: None
+
+**Response** (200 OK):
+```json
+{
+ "entities": [
+ {
+ "id": "integer",
+ "title": "string",
+ "text": "string",
+ "viewed": "boolean"
+ }
+ ],
+ "count": "integer",
+ "pagination": {
+ "current": {
+ "cursor": "integer",
+ "limit": "integer",
+ "query": "string"
+ },
+ "next": {
+ "cursor": "integer",
+ "limit": "integer",
+ "query": "string"
+ },
+ "count_available": "integer",
+ "current_page": "integer",
+ "remaining_pages": "integer",
+ "total_pages": "integer"
+ }
+}
+```
+
+**Response Schema**:
+- `entities`: Array (optional) - Array of MessageEntity objects:
+ - `id`: Integer (required) - Message ID
+ - `title`: String (required) - Message title
+ - `text`: String (optional) - Message text content
+ - `viewed`: Boolean (required) - Whether message has been viewed
+- `count`: Integer (required) - Total count of messages
+- `pagination`: Object (optional) - Pagination metadata (same structure as admin list)
+
+**Error Responses**:
+- `400 Bad Request`: Invalid arguments (pagination errors)
+- `401 Unauthorized`: Missing or invalid token
+
+**Response Headers**:
+- `X-Authorization-Expire`: Token expiration timestamp
+
+**Notes**:
+- Returns messages for the authenticated user
+- Pagination is optional but recommended for large message lists
+
+---
+
+### 13. Get Single Message
+
+**Endpoint**: `GET /inbox/messages/:id`
+
+**Authentication Required**: Yes (Bearer token)
+
+**Request Headers**:
+```
+Authorization: Bearer {token}
+```
+
+**URL Parameters**:
+- `id`: Integer (required) - Message ID
+
+**Request Body**: None
+
+**Response** (200 OK):
+```json
+{
+ "id": "integer",
+ "title": "string",
+ "text": "string",
+ "viewed": "boolean"
+}
+```
+
+**Response Schema**:
+- `id`: Integer (required) - Message ID
+- `title`: String (required) - Message title
+- `text`: String (optional) - Message text content
+- `viewed`: Boolean (required) - Whether message has been viewed
+
+**Error Responses**:
+- `400 Bad Request`: Invalid arguments (invalid message ID or access denied)
+- `401 Unauthorized`: Missing or invalid token
+
+**Response Headers**:
+- `X-Authorization-Expire`: Token expiration timestamp
+
+**Notes**:
+- Returns a single message by ID
+- User can only access their own messages
+
+---
+
+### 14. Delete Message by ID
+
+**Endpoint**: `DELETE /inbox/messages/:id`
+
+**Authentication Required**: Yes (Bearer token)
+
+**Request Headers**:
+```
+Authorization: Bearer {token}
+```
+
+**URL Parameters**:
+- `id`: Integer (required) - Message ID
+
+**Request Body**: None
+
+**Response** (200 OK):
+```json
+{
+ "type": "symbol",
+ "ids": ["array", "of", "integers"],
+ "count": "integer"
+}
+```
+
+**Response Schema**:
+- `type`: Symbol (required) - Type of modification (e.g., "delete")
+- `ids`: Array (required) - Array of message IDs that were deleted
+- `count`: Integer (required) - Number of messages deleted
+
+**Error Responses**:
+- `400 Bad Request`: Invalid arguments
+- `401 Unauthorized`: Missing or invalid token
+
+**Response Headers**:
+- `X-Authorization-Expire`: Token expiration timestamp
+
+**Notes**:
+- Deletes a single message by ID
+- User can only delete their own messages
+
+---
+
+### 15. Acknowledge Messages (Mark as Viewed)
+
+**Endpoint**: `POST /inbox/messages/ack`
+
+**Authentication Required**: Yes (Bearer token)
+
+**Request Headers**:
+```
+Authorization: Bearer {token}
+Content-Type: application/json
+```
+
+**Request Body Schema**:
+```json
+{
+ "ids": ["array", "of", "integers"]
+}
+```
+
+**Field Types**:
+- `ids`: Array (required) - Array of message IDs (integers) to mark as viewed
+
+**Response** (200 OK):
+```json
+{
+ "type": "symbol",
+ "ids": ["array", "of", "integers"],
+ "count": "integer"
+}
+```
+
+**Response Schema**:
+- `type`: Symbol (required) - Type of modification (e.g., "viewed")
+- `ids`: Array (required) - Array of message IDs that were acknowledged
+- `count`: Integer (required) - Number of messages acknowledged
+
+**Error Responses**:
+- `400 Bad Request`: Invalid arguments (invalid IDs or validation errors)
+- `401 Unauthorized`: Missing or invalid token
+
+**Response Headers**:
+- `X-Authorization-Expire`: Token expiration timestamp
+
+**Notes**:
+- Marks multiple messages as viewed/acknowledged
+- `ids` must be an array of message IDs
+- Example: `{ "ids": [1, 2, 3] }`
+
+---
+
+### 16. Delete Messages (Bulk)
+
+**Endpoint**: `POST /inbox/messages/delete`
+
+**Authentication Required**: Yes (Bearer token)
+
+**Request Headers**:
+```
+Authorization: Bearer {token}
+Content-Type: application/json
+```
+
+**Request Body Schema**:
+```json
+{
+ "ids": ["array", "of", "integers"]
+}
+```
+
+**Field Types**:
+- `ids`: Array (required) - Array of message IDs (integers) to delete
+
+**Response** (200 OK):
+```json
+{
+ "type": "symbol",
+ "ids": ["array", "of", "integers"],
+ "count": "integer"
+}
+```
+
+**Response Schema**:
+- `type`: Symbol (required) - Type of modification (e.g., "delete")
+- `ids`: Array (required) - Array of message IDs that were deleted
+- `count`: Integer (required) - Number of messages deleted
+
+**Error Responses**:
+- `400 Bad Request`: Invalid arguments (invalid IDs or validation errors)
+- `401 Unauthorized`: Missing or invalid token
+
+**Response Headers**:
+- `X-Authorization-Expire`: Token expiration timestamp
+
+**Notes**:
+- Deletes multiple messages at once
+- `ids` must be an array of message IDs
+- Example: `{ "ids": [1, 2, 3] }`
+
+---
+
+### 17. Get Message Blast Metadata
+
+**Endpoint**: `GET /inbox/blast`
+
+**Authentication Required**: Yes (Bearer token with admin role)
+
+**Request Headers**:
+```
+Authorization: Bearer {token}
+```
+
+**Request Body**: None
+
+**Response** (200 OK):
+```json
+{
+ "entities": [
+ {
+ "id": "integer",
+ "title": "string",
+ "text": "string",
+ "existing_users": "boolean",
+ "new_users": "boolean",
+ "created_by": {
+ "id": "integer",
+ "username": "string",
+ "email": "string"
+ }
+ }
+ ],
+ "count": "integer",
+ "pagination": {
+ "current": {
+ "cursor": "integer",
+ "limit": "integer",
+ "query": "string"
+ },
+ "next": {
+ "cursor": "integer",
+ "limit": "integer",
+ "query": "string"
+ },
+ "count_available": "integer",
+ "current_page": "integer",
+ "remaining_pages": "integer",
+ "total_pages": "integer"
+ }
+}
+```
+
+**Response Schema**:
+- `entities`: Array (optional) - Array of MessageBlastEntity objects:
+ - `id`: Integer (required) - Message blast ID
+ - `title`: String (required) - Message blast title
+ - `text`: String (optional) - Message blast text content
+ - `existing_users`: Boolean (required) - Whether sent to existing users
+ - `new_users`: Boolean (required) - Whether sent to new users
+ - `created_by`: Object (optional) - User object of creator (may be omitted for performance)
+- `count`: Integer (required) - Total count of message blasts
+- `pagination`: Object (optional) - Pagination metadata (same structure as other endpoints)
+
+**Error Responses**:
+- `401 Unauthorized`: Missing or invalid token
+- `403 Forbidden`: User does not have required permissions
+
+**Response Headers**:
+- `X-Authorization-Expire`: Token expiration timestamp
+
+**Notes**:
+- Requires admin authorization
+- Returns metadata about message blasts
+
+---
+
+### 18. Get Single Message Blast
+
+**Endpoint**: `GET /inbox/blast/:id`
+
+**Authentication Required**: Yes (Bearer token with admin role)
+
+**Request Headers**:
+```
+Authorization: Bearer {token}
+```
+
+**URL Parameters**:
+- `id`: Integer (required) - Message Blast ID
+
+**Request Body**: None
+
+**Response** (200 OK):
+```json
+{
+ "id": "integer",
+ "title": "string",
+ "text": "string",
+ "existing_users": "boolean",
+ "new_users": "boolean",
+ "created_by": {
+ "id": "integer",
+ "username": "string",
+ "email": "string",
+ "first_name": "string",
+ "last_name": "string"
+ }
+}
+```
+
+**Response Schema**:
+- `id`: Integer (required) - Message blast ID
+- `title`: String (required) - Message blast title
+- `text`: String (optional) - Message blast text content
+- `existing_users`: Boolean (required) - Whether sent to existing users
+- `new_users`: Boolean (required) - Whether sent to new users
+- `created_by`: Object (optional) - User object of creator with configured default attributes
+
+**Error Responses**:
+- `400 Bad Request`: Invalid arguments (invalid ID)
+- `401 Unauthorized`: Missing or invalid token
+- `403 Forbidden`: User does not have required permissions
+
+**Response Headers**:
+- `X-Authorization-Expire`: Token expiration timestamp
+
+---
+
+### 19. Create Message Blast
+
+**Endpoint**: `POST /inbox/blast`
+
+**Authentication Required**: Yes (Bearer token with admin role)
+
+**Request Headers**:
+```
+Authorization: Bearer {token}
+Content-Type: application/json
+```
+
+**Request Body Schema**:
+```json
+{
+ "existing_users": "boolean",
+ "new_users": "boolean",
+ "title": "string",
+ "text": "string",
+ "id": "integer"
+}
+```
+
+**Field Types**:
+- `existing_users`: Boolean (required) - Accepts: true, false, "true", "false", 1, 0, "1", "0"
+- `new_users`: Boolean (required) - Accepts: true, false, "true", "false", 1, 0, "1", "0"
+- `title`: String (required) - Message blast title
+- `text`: String (required) - Message blast text content
+- `id`: Integer (optional) - Message blast ID (for updates)
+
+**Response** (200 OK):
+```json
+{
+ "id": "integer",
+ "title": "string",
+ "text": "string",
+ "existing_users": "boolean",
+ "new_users": "boolean",
+ "created_by": {
+ "id": "integer",
+ "username": "string",
+ "email": "string",
+ "first_name": "string",
+ "last_name": "string"
+ }
+}
+}
+```
+
+**Response Schema**:
+- `id`: Integer (optional) - Message blast ID
+- `title`: String (required) - Message blast title
+- `text`: String (required) - Message blast text content
+- `existing_users`: Boolean (required) - Whether sent to existing users
+- `new_users`: Boolean (required) - Whether sent to new users
+- `created_by`: Object (required) - User object of creator with configured default attributes
+
+**Error Responses**:
+- `400 Bad Request`: Invalid arguments (validation errors)
+- `401 Unauthorized`: Missing or invalid token
+- `403 Forbidden`: User does not have required permissions
+
+**Response Headers**:
+- `X-Authorization-Expire`: Token expiration timestamp
+
+**Notes**:
+- Creates a message blast that can be sent to existing users, new users, or both
+- `existing_users` and `new_users` are boolean flags
+- The creator is automatically set to the authenticated admin user
+
+---
+
+### 20. Modify Message Blast
+
+**Endpoint**: `PATCH /inbox/blast/:id`
+
+**Authentication Required**: Yes (Bearer token with admin role)
+
+**Request Headers**:
+```
+Authorization: Bearer {token}
+Content-Type: application/json
+```
+
+**URL Parameters**:
+- `id`: Integer (required) - Message Blast ID
+
+**Request Body Schema**:
+```json
+{
+ "existing_users": "boolean",
+ "new_users": "boolean",
+ "title": "string",
+ "text": "string"
+}
+```
+
+**Field Types**:
+- `existing_users`: Boolean (required) - Accepts: true, false, "true", "false", 1, 0, "1", "0"
+- `new_users`: Boolean (required) - Accepts: true, false, "true", "false", 1, 0, "1", "0"
+- `title`: String (required) - Message blast title
+- `text`: String (required) - Message blast text content
+
+**Response** (200 OK):
+```json
+{
+ "id": "integer",
+ "title": "string",
+ "text": "string",
+ "existing_users": "boolean",
+ "new_users": "boolean",
+ "created_by": {
+ "id": "integer",
+ "username": "string",
+ "email": "string",
+ "first_name": "string",
+ "last_name": "string"
+ }
+}
+```
+
+**Response Schema**:
+- Same as Create Message Blast response
+
+**Error Responses**:
+- `400 Bad Request`: Invalid arguments (validation errors)
+- `401 Unauthorized`: Missing or invalid token
+- `403 Forbidden`: User does not have required permissions
+
+**Response Headers**:
+- `X-Authorization-Expire`: Token expiration timestamp
+
+---
+
+### 21. Delete Message Blast
+
+**Endpoint**: `DELETE /inbox/blast/:id`
+
+**Authentication Required**: Yes (Bearer token with admin role)
+
+**Request Headers**:
+```
+Authorization: Bearer {token}
+```
+
+**URL Parameters**:
+- `id`: Integer (required) - Message Blast ID
+
+**Request Body**: None
+
+**Response** (200 OK):
+```json
+{
+ "id": "integer",
+ "msg": "string"
+}
+```
+
+**Response Schema**:
+- `id`: Integer (required) - Deleted message blast ID
+- `msg`: String (required) - "Message Blast message deleted"
+
+**Error Responses**:
+- `400 Bad Request`: Invalid arguments (invalid ID)
+- `401 Unauthorized`: Missing or invalid token
+- `403 Forbidden`: User does not have required permissions
+
+**Response Headers**:
+- `X-Authorization-Expire`: Token expiration timestamp
+
+---
+
+## Complete Workflows
+
+### Signup Workflow
+
+**Step 1: Check Username Availability** (Optional)
+```
+GET /username/available/:username
+```
+- Verify username is available before signup
+- Response indicates if username is available and valid
+
+**Step 2: Create Account**
+```
+POST /auth/create
+Content-Type: application/json
+
+{
+ "first_name": "John",
+ "last_name": "Doe",
+ "username": "johndoe",
+ "email": "john@example.com",
+ "password": "securepassword123",
+ "password_confirmation": "securepassword123"
+}
+```
+- Response includes user details (no token yet)
+- Status: 201 Created
+
+**Step 3: Login**
+```
+POST /auth/login
+Content-Type: application/json
+
+{
+ "identifier": "johndoe",
+ "password": "securepassword123"
+}
+```
+- Use `identifier` (can be username or email) with `password`
+- Response includes JWT `token` and user object
+- Status: 201 Created
+
+**Step 4: Email Verification** (If enabled)
+```
+POST /auth/email/send
+Authorization: Bearer {token}
+```
+- Request verification code
+- Code is sent to user's email
+- Status: 201 Created
+
+```
+POST /auth/email/verify
+Authorization: Bearer {token}
+Content-Type: application/json
+
+{
+ "code": "verification_code_from_email"
+}
+```
+- Verify with code from email
+- Email is marked as validated
+- Status: 201 Created
+
+**Step 5: Access Protected Endpoints**
+- Use `Authorization: Bearer {token}` header for all subsequent requests
+- Monitor `X-Authorization-Expire` header for token expiration
+
+---
+
+### Login Workflow
+
+**Step 1: Login**
+```
+POST /auth/login
+Content-Type: application/json
+
+{
+ "identifier": "user@example.com",
+ "password": "password123"
+}
+```
+- Use `identifier` (can be username or email) with `password`
+- Response includes JWT `token` and user object
+- Status: 201 Created
+
+**Step 2: Store Token**
+- Save the `token` value from response
+- Use in `Authorization: Bearer {token}` header for all authenticated requests
+
+**Step 3: Monitor Token Expiration**
+- Check `X-Authorization-Expire` header on each response
+- Parse timestamp and calculate time until expiration
+- Refresh token before expiration if needed
+
+**Step 4: Refresh Token** (When Needed)
+```
+GET /user
+Authorization: Bearer {old_token}
+X-Authorization-Reset: true
+```
+- Include `X-Authorization-Reset: true` header in any authenticated request
+- Response will include new token in `X-Authorization-Reset` header
+- Update stored token with new value
+- Use new token for all subsequent requests
+
+**Example Token Refresh Flow**:
+1. Client makes request with `X-Authorization-Reset: true`
+2. Server processes request and generates new token
+3. Server returns response with:
+ - `X-Authorization-Reset: {new_token}` header
+ - `X-Authorization-Expire: {new_expiration}` header
+ - Normal response body
+4. Client extracts new token from `X-Authorization-Reset` header
+5. Client replaces stored token with new token
+6. Client uses new token for all future requests
+
+---
+
+### Admin Workflow
+
+**Step 1: Login as Admin**
+```
+POST /auth/login
+Content-Type: application/json
+
+{
+ "username": "admin",
+ "password": "adminpassword"
+}
+```
+- Receive JWT token
+- User must have admin role
+
+**Step 2: List Users**
+```
+GET /admin?pagination=true&limit=50&page=1
+Authorization: Bearer {token}
+```
+- Query params: `pagination=true&limit=50&page=1`
+- Review user list with pagination
+- Status: 200 OK
+
+**Step 3: Modify User**
+```
+POST /admin/modify
+Authorization: Bearer {token}
+Content-Type: application/json
+
+{
+ "user_id": 123,
+ "email": "newemail@example.com",
+ "first_name": "Updated"
+}
+```
+- Update user attributes
+- Status: 201 Created
+
+**Step 4: Modify User Roles**
+```
+POST /admin/modify/role
+Authorization: Bearer {token}
+Content-Type: application/json
+
+{
+ "user_id": 123,
+ "roles": ["admin"]
+}
+```
+- Assign or update user roles
+- Status: 201 Created
+
+**Step 5: Create Message Blast**
+```
+POST /inbox/blast
+Authorization: Bearer {token}
+Content-Type: application/json
+
+{
+ "existing_users": true,
+ "new_users": false,
+ "title": "Important Announcement",
+ "text": "This is an important message for all existing users."
+}
+```
+- Send messages to users
+- Status: 200 OK
+
+---
+
+### Email Verification Workflow
+
+**Step 1: User Logs In**
+```
+POST /auth/login
+Content-Type: application/json
+
+{
+ "username": "user",
+ "password": "password"
+}
+```
+- Receive token
+- Status: 201 Created
+
+**Step 2: Request Verification Code**
+```
+POST /auth/email/send
+Authorization: Bearer {token}
+```
+- Code is sent to user's email
+- Status: 201 Created
+
+**Step 3: Verify Email**
+```
+POST /auth/email/verify
+Authorization: Bearer {token}
+Content-Type: application/json
+
+{
+ "code": "code_from_email"
+}
+```
+- Email is marked as validated
+- `email_validated` flag is set to `true`
+- Status: 201 Created
+
+**Step 4: Access Full Features**
+- User can now access all protected endpoints
+- No more email verification restrictions
+
+---
+
+## Error Handling
+
+### Error Response Format
+
+All error responses follow a standard format:
+
+**Base Error Schema**:
+```json
+{
+ "status": "string",
+ "message": "string"
+}
+```
+
+**Field Types**:
+- `status`: String (required) - HTTP status code as string (e.g., "401", "403", "400")
+- `message`: String (required) - Human-readable error message
+
+**Invalid Arguments Error Schema** (for validation errors):
+```json
+{
+ "status": "string",
+ "message": "string",
+ "invalid_arguments": [
+ {
+ "schema": "object",
+ "argument": "string",
+ "argument_type": "string",
+ "reason": "string"
+ }
+ ],
+ "invalid_argument_keys": ["array", "of", "strings"]
+}
+```
+
+**Field Types**:
+- `status`: String (required) - HTTP status code as string
+- `message`: String (required) - Human-readable error message
+- `invalid_arguments`: Array (optional) - Array of InvalidArgument objects:
+ - `schema`: Object (required) - Schema definition
+ - `argument`: String (required) - Field name that failed validation
+ - `argument_type`: String (required) - Expected type
+ - `reason`: String (optional) - Reason for validation failure
+- `invalid_argument_keys`: Array (optional) - Array of field names that failed validation
+
+### HTTP Status Codes
+
+- **200 OK**: Request successful
+- **201 Created**: Resource created successfully
+- **400 Bad Request**: Invalid request format or validation errors
+- **401 Unauthorized**: Authentication failure (missing/invalid token, invalid credentials)
+- **403 Forbidden**: Authorization failure (insufficient permissions)
+- **500 Internal Server Error**: Server error
+
+### Common Error Scenarios
+
+**1. Missing Authentication Token**
+```
+Status: 401 Unauthorized
+Response: {
+ "status": "401",
+ "message": "Bearer token missing"
+}
+```
+
+**2. Invalid Token Format**
+```
+Status: 401 Unauthorized
+Response: {
+ "status": "401",
+ "message": "Invalid Bearer token format"
+}
+```
+
+**3. Expired Token**
+```
+Status: 401 Unauthorized
+Response: {
+ "status": "401",
+ "message": "Unauthorized Access. Invalid Authorization token"
+}
+```
+
+**4. Invalid Verifier Token**
+```
+Status: 401 Unauthorized
+Response: {
+ "status": "401",
+ "message": "Unauthorized Access. Token is no longer valid"
+}
+```
+
+**5. Email Verification Required**
+```
+Status: 412 Precondition Failed
+Response: {
+ "status": "412",
+ "message": "Email must be verified to continue",
+ "meta": {
+ "email_validated": false
+ }
+}
+```
+
+**6. Insufficient Permissions**
+```
+Status: 403 Forbidden
+Response: {
+ "status": "403",
+ "message": "User does not have required permissions"
+}
+```
+
+**7. Validation Errors**
+```
+Status: 400 Bad Request
+Response: {
+ "status": "400",
+ "message": "Invalid arguments provided",
+ "invalid_arguments": [
+ {
+ "schema": {...},
+ "argument": "email",
+ "argument_type": "String",
+ "reason": "Email format is invalid"
+ }
+ ],
+ "invalid_argument_keys": ["email"]
+}
+```
+
+**8. Invalid User ID (Admin Endpoints)**
+```
+Status: 400 Bad Request
+Response: {
+ "status": "400",
+ "message": "Invalid user"
+}
+```
+
+### Error Handling Best Practices
+
+1. **Always Check Status Codes**: Don't assume success based on response body alone
+2. **Parse Error Messages**: Display user-friendly error messages to end users
+3. **Handle Invalid Arguments**: Check `invalid_arguments` array for field-specific errors
+4. **Token Expiration**: Monitor `X-Authorization-Expire` and refresh before expiration
+5. **Retry Logic**: Implement retry logic for 401 errors (after refreshing token)
+6. **Logging**: Log error responses for debugging (but never log tokens)
+
+---
+
+## Configuration Dependencies
+
+Some endpoints may only be available based on configuration:
+
+1. **Username Availability Endpoint** (`GET /username/available/:username`)
+ - Requires `CommandTower.config.username.realtime_username_check` to be enabled
+ - If disabled, this endpoint may not be available
+
+2. **Email Verification Endpoints** (`POST /auth/email/send`, `POST /auth/email/verify`)
+ - Require `CommandTower.config.login.plain_text.email_verify` to be enabled
+ - If disabled, these endpoints may not be available
+
+3. **Plain Text Authentication Endpoints** (`POST /auth/login`, `POST /auth/create`)
+ - Require `CommandTower.config.login.plain_text.enable` to be enabled
+ - If disabled, these endpoints may not be available
+
+4. **User Attributes**
+ - User object attributes returned are configurable via `CommandTower.config.user.default_attributes`
+ - Default attributes typically include: `id`, `email`, `first_name`, `last_name`, `username`, `email_validated`, `roles`, `created_at`, `verifier_token`, `last_known_timezone`
+ - Additional attributes can be configured
+
+---
+
+## Pagination
+
+### Pagination Parameters
+
+Pagination is available on list endpoints when explicitly enabled.
+
+**Query Parameters**:
+- `pagination=true` (String, required) - Must be set to enable pagination
+- `limit` (Integer, optional) - Number of records per page (defaults to configured default, typically 10)
+- `page` (Integer, optional) - Page number starting from 1
+- `cursor` (Integer, optional) - Cursor position (takes precedence over page)
+
+**Example**:
+```
+GET /admin?pagination=true&limit=50&page=2
+GET /admin?pagination=true&limit=50&cursor=100
+```
+
+### Pagination Response Schema
+
+```json
+{
+ "pagination": {
+ "current": {
+ "cursor": "integer",
+ "limit": "integer",
+ "query": "string"
+ },
+ "next": {
+ "cursor": "integer",
+ "limit": "integer",
+ "query": "string"
+ },
+ "count_available": "integer",
+ "current_page": "integer",
+ "remaining_pages": "integer",
+ "total_pages": "integer"
+ }
+}
+```
+
+**Field Types**:
+- `current`: Object (required) - Current page information
+ - `cursor`: Integer (required) - Current cursor position
+ - `limit`: Integer (required) - Current page limit
+ - `query`: String (required) - Query identifier
+- `next`: Object (optional) - Next page information (same structure, null if no next page)
+- `count_available`: Integer (optional) - Number of records available
+- `current_page`: Integer (optional) - Current page number (1-based)
+- `remaining_pages`: Integer (optional) - Number of pages remaining
+- `total_pages`: Integer (optional) - Total number of pages
+
+### Pagination Best Practices
+
+1. **Use Cursor-Based Pagination**: Recommended for large datasets
+2. **Set Appropriate Limits**: Balance between performance and user experience
+3. **Check for Next Page**: Use `next` object to determine if more pages exist
+4. **Default Limit**: Default is typically 10 (configurable)
+
+---
+
+## Additional Notes
+
+### User Object Attributes
+
+The User object returned in various endpoints includes attributes based on configuration. Common attributes include:
+
+- `id`: Integer - User ID
+- `username`: String - Username
+- `email`: String - Email address
+- `first_name`: String - First name
+- `last_name`: String - Last name
+- `email_validated`: Boolean - Whether email is verified
+- `roles`: Array - User roles
+- `created_at`: String/Timestamp - Account creation timestamp
+- `verifier_token`: String - Token for session invalidation
+- `last_known_timezone`: String - User's timezone
+
+Additional attributes may be included based on `CommandTower.config.user.default_attributes` configuration.
+
+### Token Refresh Best Practices
+
+1. **Refresh Before Expiration**: Monitor `X-Authorization-Expire` and refresh when less than 5 minutes remain
+2. **After Authenticated Password Change**: `POST /auth/password/change` invalidates all JWTs via verifier rotation and does **not** re-issue a token — clear local auth and Sign In again (refresh will fail)
+3. **Refresh After Role Changes**: Refresh token after admin modifies user roles
+4. **Don't Refresh Every Request**: Only refresh when necessary to avoid unnecessary latency
+5. **Handle Refresh Failures**: Implement fallback logic if token refresh fails
+
+### Security Considerations
+
+1. **HTTPS Only**: Always use HTTPS in production environments
+2. **Token Storage**: Store tokens securely (secure HTTP-only cookies, secure storage on mobile)
+3. **Token Transmission**: Never expose tokens in URLs or logs
+4. **Token Expiration**: Respect token expiration and refresh appropriately
+5. **Verifier Token Reset**: Use verifier token reset to invalidate all sessions when needed
+6. **Password Security**: Enforce strong password policies
+7. **Email Verification**: Enable email verification for production environments
+
+---
+
+## Summary
+
+This API reference provides comprehensive documentation for all CommandTower endpoints, including:
+
+- Complete request/response schemas with field types
+- Authentication and token management (including token refresh)
+- User management workflows
+- Admin functionality
+- Inbox messaging system
+- Error handling
+- Pagination
+- Configuration dependencies
+
+For implementation details, refer to the integration tests in `/spec/integration_test/` and the service implementations in `/app/services/command_tower/`.
diff --git a/docs/authentication.md b/docs/authentication.md
index 7467699..9ac8be9 100644
--- a/docs/authentication.md
+++ b/docs/authentication.md
@@ -10,6 +10,8 @@ before_action :authenticate_user!
This action will authenticate the user and set `current_user` to the user passed in via the JWT token.
+**Token Extraction**: The authentication system uses a centralized `CommandTower::Jwt::AuthorizationHelper` module that handles all token extraction logic. It checks the `Authorization` header first, then falls back to the HttpOnly cookie (if cookie authentication is enabled). All header and cookie access is abstracted through this helper module.
+
Every API request will return a header that indicates when the current token will expire:
```
X-Authentication-Expire="2025-01-16 04:36:29 +0000"
@@ -18,9 +20,54 @@ X-Authentication-Expire="2025-01-16 04:36:29 +0000"
### Header Token
For routes that expect user authentication, the client must set the Header value:
```
-Authentication="Bearer: {token value}"
+Authorization: Bearer {token value}
```
+**Important**: The header format must be exactly `Bearer {token}` with a space between "Bearer" and the token. Malformed headers will result in `401 Unauthorized` with the message "Invalid Bearer token format".
+
+### Cookie Token (Optional)
+CommandTower supports HttpOnly cookie-based authentication for web applications. When enabled, the JWT token is automatically set as an HttpOnly cookie on successful login, and the authentication system will fall back to the cookie if the Authorization header is missing.
+
+**📖 For detailed web application setup, CORS configuration, and best practices, see the [Cookie Authentication Guide](cookie_authentication_guide.md).**
+
+**To enable cookie authentication**, add the following to your host app's initializer:
+
+```ruby
+CommandTower.configure do |config|
+ config.jwt.cookie.enabled = true
+ # Optional: customize cookie settings
+ # config.jwt.cookie.name = "ct_jwt" # Default: "ct_jwt"
+ # config.jwt.cookie.same_site = :lax # Default: :lax
+ # config.jwt.cookie.secure = true # Default: Rails.env.production?
+ # config.jwt.cookie.path = "/" # Default: "/"
+ # config.jwt.cookie.domain = nil # Default: nil (host-only)
+
+ # Optional: Enable double-submit CSRF protection
+ # config.jwt.cookie.csrf.enabled = false # Default: false
+ # config.jwt.cookie.csrf.cookie_name = "ct_csrf" # Default: "ct_csrf"
+ # config.jwt.cookie.csrf.header_name = "X-CSRF-Token" # Default: "X-CSRF-Token"
+ # config.jwt.cookie.csrf.rotate_on_login = true # Default: true
+ # config.jwt.cookie.csrf.rotate_on_reset = true # Default: true
+end
+```
+
+**Security considerations:**
+- Cookies are HttpOnly by default (not accessible via JavaScript)
+- Cookies use SameSite=Lax by default to prevent CSRF attacks
+- Optional double-submit CSRF protection can be enabled for additional security (see [Cookie Authentication Guide](cookie_authentication_guide.md))
+- Secure flag is automatically set to `true` in production
+- Cookie TTL matches the JWT TTL configuration
+
+**Behavior:**
+- On successful login, the token is set in both the response header (`X-Authorization-Reset`) and the HttpOnly cookie (if enabled)
+- Authentication checks the `Authorization` header first, then falls back to the cookie if the header is missing
+- When tokens are refreshed (via `X-Authorization-Reset: true`), the cookie is automatically updated
+- Use `POST /auth/logout` to clear the cookie (browser-only logout, does not reset `verifier_token`)
+
+**Token Extraction Priority:**
+1. `Authorization: Bearer {token}` header (checked first)
+2. HttpOnly cookie (fallback, only if header is missing and cookie auth is enabled)
+
### How to get the JWT Token
Each Authentication strategy has a `/login` route. This route will return to you a valid token that can then be used for subsequent API Calls
@@ -38,6 +85,8 @@ X-Authentication-Reset="Regenerated token"
**Use Caution** when regenerating the JWT token. While nothing is stopping you from regenerating on every request, it will add some latency that is may not be needed.
+**Note:** When cookie authentication is enabled, token refresh automatically updates the cookie as well.
+
## Encryption
JWT tokens are encrypted at rest. The Encryption key is delivered as the `hmac_secret` in the configuration or set as an ENV variable `SECRET_KEY_BASE`.
@@ -48,4 +97,4 @@ JWT can hold a payload. This payload is encrypted and is sent as part of the enc
The `expires_at` payload is a timestamp for when the token must be regenerated. After the token "expires", the User will no longer be authenticated to actions and a `401` is returned.
### Verifier Token
-Each user has a `verifier_token` encrypted into the payload of the JWT token. This token must match what is on the User's Record. If it does not match, a 403 is returned. A User or and Admin can reset the versifier token any time they want to log out of all sessions.
+Each user has a `verifier_token` encrypted into the payload of the JWT token. This token must match what is on the User's Record. If it does not match, authentication fails. A User or an Admin can reset the verifier token any time they want to log out of all sessions. Authenticated password change (`POST /auth/password/change`) rotates the verifier automatically.
diff --git a/docs/authentication_authorization_guide.md b/docs/authentication_authorization_guide.md
new file mode 100644
index 0000000..139b947
--- /dev/null
+++ b/docs/authentication_authorization_guide.md
@@ -0,0 +1,1948 @@
+# CommandTower Authentication & Authorization Guide
+
+## Table of Contents
+
+1. [Introduction](#introduction)
+2. [Authentication System](#authentication-system)
+ - [2.1 Overview](#21-overview)
+ - [2.2 Token Generation](#22-token-generation)
+ - [2.3 Token Requirements](#23-token-requirements)
+ - [2.4 Token Validation](#24-token-validation)
+ - [2.5 Token Expiration](#25-token-expiration)
+ - [2.6 Token Refresh](#26-token-refresh)
+ - [2.7 Token Invalidation](#27-token-invalidation)
+3. [Authorization System (RBAC)](#authorization-system-rbac)
+ - [3.1 Overview](#31-overview)
+ - [3.2 Core Concepts](#32-core-concepts)
+ - [3.3 Authorization Requirements](#33-authorization-requirements)
+ - [3.4 Role Definitions](#34-role-definitions)
+ - [3.5 Entity Definitions](#35-entity-definitions)
+ - [3.6 Authorization Validation Process](#36-authorization-validation-process)
+ - [3.7 Authorization Failure (403 Forbidden)](#37-authorization-failure-403-forbidden)
+4. [Integration Guide](#integration-guide)
+ - [4.1 Setting Up Authentication](#41-setting-up-authentication)
+ - [4.2 Setting Up Authorization](#42-setting-up-authorization)
+ - [4.3 Client Implementation](#43-client-implementation)
+5. [Configuration](#configuration)
+ - [5.1 JWT Configuration](#51-jwt-configuration)
+ - [5.2 Authorization Configuration](#52-authorization-configuration)
+6. [Security Considerations](#security-considerations)
+7. [Troubleshooting](#troubleshooting)
+8. [Examples and Use Cases](#examples-and-use-cases)
+9. [API Reference](#api-reference)
+
+---
+
+## Introduction
+
+### Overview of CommandTower Authentication & Authorization
+
+CommandTower provides a comprehensive authentication and authorization system built on JWT (JSON Web Tokens) and RBAC (Role-Based Access Control). This guide explains how these systems work together to secure your API endpoints.
+
+**Authentication** answers the question: "Who are you?" It verifies the identity of the user making the request using JWT tokens.
+
+**Authorization** answers the question: "What are you allowed to do?" It verifies that the authenticated user has the necessary permissions (roles) to perform the requested action.
+
+### Relationship between Authentication and Authorization
+
+Authentication must occur **before** authorization. The flow is:
+
+1. **Authentication** (`authenticate_user!`): Validates the JWT token and sets `current_user`
+2. **Authorization** (`authorize_user!`): Checks if `current_user` has the required role(s) for the action
+
+If authentication fails, a `401 Unauthorized` is returned. If authorization fails, a `403 Forbidden` is returned.
+
+### HTTP Status Codes: 401 vs 403
+
+- **401 Unauthorized**: Authentication failure
+ - Missing or invalid token
+ - Expired token
+ - Invalid verifier token
+ - User not found
+
+- **403 Forbidden**: Authorization failure
+ - User is authenticated but lacks required role(s)
+ - User's roles don't match the required permissions for the controller/action
+
+---
+
+## Authentication System
+
+### 2.1 Overview
+
+CommandTower uses **JWT (JSON Web Tokens)** for authentication. JWT tokens are stateless, encrypted tokens that contain user identity information.
+
+#### Token Structure
+
+JWT tokens contain the following encrypted payload:
+
+```json
+{
+ "user_id": 123,
+ "verifier_token": "abc123xyz",
+ "generated_at": 1705440000
+}
+```
+
+- **`user_id`** (Integer): The authenticated user's ID
+- **`verifier_token`** (String): A token that must match the user's current verifier_token in the database
+- **`generated_at`** (Integer): Unix timestamp when the token was created
+
+#### Encryption
+
+JWT tokens are encrypted using the **HS256 algorithm** with a secret key configured via:
+- `CommandTower.config.jwt.hmac_secret` (defaults to `ENV['SECRET_KEY_BASE']`)
+
+The secret key is used to both sign and verify tokens, ensuring they cannot be tampered with.
+
+### 2.2 Token Generation
+
+#### Initial Token Generation via Login
+
+Tokens are generated when a user successfully logs in through the authentication endpoints.
+
+**Endpoint**: `POST /auth/login`
+
+**Request Headers**:
+```
+Content-Type: application/json
+```
+
+**Request Body**:
+```json
+{
+ "identifier": "johndoe",
+ "password": "securepassword123"
+}
+```
+
+**Field Requirements**:
+- `identifier` must be provided (can be username or email)
+- `password` is required
+- The system will check both username and email fields to find the user
+
+**Response** (201 Created):
+```json
+{
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInZlcmlmaWVyX3Rva2VuIjoiYWJjMTIzIiwiZ2VuZXJhdGVkX2F0IjoxNzA1NDQwMDAwfQ.signature",
+ "header_name": "Authorization",
+ "message": "Successfully logged user in",
+ "user": {
+ "id": 123,
+ "username": "johndoe",
+ "email": "john@example.com",
+ "first_name": "John",
+ "last_name": "Doe",
+ "email_validated": true,
+ "roles": ["user"],
+ "created_at": "2024-01-15T10:00:00Z",
+ "verifier_token": "abc123xyz",
+ "last_known_timezone": "America/New_York"
+ }
+}
+```
+
+**Response Schema**:
+- `token` (String, required): JWT token to use in Authorization header for subsequent requests
+- `header_name` (String, required): Always "Authorization"
+- `message` (String, required): Success message
+- `user` (Object, required): User object with configured default attributes
+
+**Error Responses**:
+- `400 Bad Request`: Invalid request format
+- `401 Unauthorized`: Invalid credentials or invalid arguments
+
+**Example Error Response** (401):
+```json
+{
+ "status": "401",
+ "message": "Unauthorized Access. Incorrect Credentials",
+ "invalid_arguments": {
+ "identifier": ["Unauthorized Access. Incorrect Credentials"],
+ "password": ["Parameter [password] is required but not present"]
+ }
+}
+```
+
+#### Token Generation Process
+
+When a user logs in successfully:
+
+1. **User Lookup**: System finds user by username or email
+2. **Password Verification**: Password is verified against stored hash
+3. **Verifier Token Retrieval**: System retrieves or generates `verifier_token` for the user
+4. **Token Creation**: `CommandTower::Jwt::LoginCreate` service creates JWT token with:
+ - `user_id`: User's database ID
+ - `verifier_token`: User's current verifier_token
+ - `generated_at`: Current Unix timestamp
+5. **Token Encoding**: Token is encoded using HS256 algorithm with HMAC secret
+6. **Response**: Token is returned to client in response body
+
+**Code Flow**:
+```ruby
+# In CommandTower::Jwt::LoginCreate
+payload = {
+ generated_at: Time.now.to_i,
+ user_id: user.id,
+ verifier_token: user.retreive_verifier_token!,
+}
+token = JWT.encode(payload, CommandTower.config.jwt.hmac_secret, "HS256")
+```
+
+### 2.3 Token Requirements
+
+#### When Tokens Are Required
+
+Tokens are required for any endpoint that uses the `before_action :authenticate_user!` callback. This includes:
+
+- User management endpoints (`/user/*`)
+- Admin endpoints (`/admin/*`)
+- Inbox endpoints (`/inbox/*`)
+- Any custom endpoints that include authentication
+
+#### Header Format
+
+**Required Header**:
+```
+Authorization: Bearer {token_value}
+```
+
+**Critical Format Requirements**:
+- The header value MUST be in the format: `Bearer {token}` (with a space between "Bearer" and the token)
+- The word "Bearer" is case-sensitive
+- Missing or malformed headers will result in `401 Unauthorized` responses
+
+**Example**:
+```
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInZlcmlmaWVyX3Rva2VuIjoiYWJjMTIzIiwiZ2VuZXJhdGVkX2F0IjoxNzA1NDQwMDAwfQ.signature
+```
+
+#### Controller Setup
+
+To require authentication on a controller:
+
+```ruby
+class MyController < CommandTower::ApplicationController
+ before_action :authenticate_user!
+
+ def my_action
+ # current_user is available here
+ end
+end
+```
+
+### 2.4 Token Validation
+
+#### Validation Process
+
+When a request includes an `Authorization` header, the following validation occurs:
+
+1. **Header Extraction**: System extracts token from `Authorization: Bearer {token}` header
+2. **Token Decoding**: `CommandTower::Jwt::Decode` service decodes the token using HMAC secret
+3. **Payload Extraction**: Extracts `user_id`, `verifier_token`, and `generated_at` from payload
+4. **User Lookup**: Finds user by `user_id` from the database
+5. **Verifier Token Match**: Compares token's `verifier_token` with user's current `verifier_token`
+6. **Expiration Check**: Validates that token hasn't expired
+7. **Email Validation** (if enabled): Checks if user's email is validated
+
+#### Validation Failure Scenarios
+
+**1. Missing Authorization Header**
+
+**Request**:
+```
+GET /user/
+(no Authorization header)
+```
+
+**Response** (401 Unauthorized):
+```json
+{
+ "status": "401",
+ "message": "Bearer token missing"
+}
+```
+
+**2. Invalid Bearer Token Format**
+
+**Request**:
+```
+GET /user/
+Authorization: InvalidFormat token123
+```
+
+**Response** (401 Unauthorized):
+```json
+{
+ "status": "401",
+ "message": "Invalid Bearer token format"
+}
+```
+
+**3. Expired Token**
+
+**Request**:
+```
+GET /user/
+Authorization: Bearer {expired_token}
+```
+
+**Response** (401 Unauthorized):
+```json
+{
+ "status": "401",
+ "message": "Unauthorized Access. Invalid Authorization token"
+}
+```
+
+**4. Invalid Verifier Token**
+
+When a user's `verifier_token` is reset (e.g., logout all sessions), all existing tokens become invalid.
+
+**Request**:
+```
+GET /user/
+Authorization: Bearer {token_with_old_verifier_token}
+```
+
+**Response** (401 Unauthorized):
+```json
+{
+ "status": "401",
+ "message": "Unauthorized Access. Token is no longer valid"
+}
+```
+
+**5. User Not Found**
+
+If the `user_id` in the token doesn't exist in the database:
+
+**Response** (401 Unauthorized):
+```json
+{
+ "status": "401",
+ "message": "Unauthorized Access. Invalid Authorization token"
+}
+```
+
+**6. Email Not Validated** (when email verification is enabled)
+
+**Response** (412 Precondition Failed):
+```json
+{
+ "status": "412",
+ "message": "Email must be verified to continue",
+ "meta": {
+ "email_validated": false
+ }
+}
+```
+
+#### Validation Code Flow
+
+```ruby
+# In CommandTower::Jwt::AuthenticateUser
+def call
+ # 1. Decode token
+ result = Decode.(token:)
+ return context.fail! if result.failure?
+
+ payload = result.payload
+
+ # 2. Validate expiration
+ expires_at = validate_generated_at!(generated_at: payload[:generated_at])
+
+ # 3. Find user
+ user = User.find(payload[:user_id])
+ return context.fail! if user.nil?
+
+ # 4. Verify verifier_token matches
+ if user.verifier_token == payload[:verifier_token]
+ context.user = user
+ else
+ context.fail!(msg: "Unauthorized Access. Token is no longer valid")
+ end
+
+ # 5. Check email validation (if enabled)
+ email_validation_required!(user:)
+
+ context.expires_at = expires_at.to_s
+end
+```
+
+### 2.5 Token Expiration
+
+#### How Expiration Works
+
+Token expiration is calculated based on:
+
+1. **`generated_at`**: Unix timestamp when token was created (stored in token payload)
+2. **TTL (Time To Live)**: Configured duration (default: 7 days)
+
+**Expiration Time** = `generated_at` + `CommandTower.config.jwt.ttl`
+
+#### Expiration Header
+
+Every authenticated request returns a header indicating when the current token will expire:
+
+**Response Header**:
+```
+X-Authorization-Expire: "2025-01-16 04:36:29 +0000"
+```
+
+**Format**: ISO 8601 timestamp string
+
+#### When Tokens Expire
+
+Tokens expire when:
+- Current time >= `generated_at` + TTL
+- After expiration, the token is no longer valid
+- All requests with expired tokens return `401 Unauthorized`
+
+**Example**:
+- Token created: `2025-01-09 04:36:29 +0000`
+- TTL: `7.days`
+- Expires: `2025-01-16 04:36:29 +0000`
+- After `2025-01-16 04:36:29 +0000`, token is invalid
+
+#### Client Monitoring Strategies
+
+**Recommended Approach**:
+
+1. **Parse Expiration Header**: Extract `X-Authorization-Expire` from every authenticated response
+2. **Calculate Time Remaining**: Calculate time until expiration
+3. **Refresh Before Expiration**: Refresh token when less than 5 minutes remain
+4. **Handle Expiration Gracefully**: If token expires, redirect to login
+
+**Example Client Implementation** (pseudo-code):
+```javascript
+function checkTokenExpiration(response) {
+ const expireHeader = response.headers['X-Authorization-Expire'];
+ if (!expireHeader) return;
+
+ const expirationTime = new Date(expireHeader);
+ const now = new Date();
+ const timeRemaining = expirationTime - now;
+ const fiveMinutes = 5 * 60 * 1000;
+
+ if (timeRemaining < fiveMinutes) {
+ refreshToken();
+ }
+}
+```
+
+### 2.6 Token Refresh
+
+#### When to Refresh Tokens
+
+Tokens should be refreshed:
+- **Before expiration**: When less than 5 minutes remain before expiration
+- **After password change**: To ensure old tokens are invalidated
+- **Periodically**: As a proactive measure (e.g., every 24 hours)
+- **After role changes**: To ensure new permissions are reflected
+
+**Best Practice**: Refresh tokens proactively before expiration rather than reactively after expiration.
+
+#### How to Request Token Refresh
+
+Token refresh can be requested on **any authenticated endpoint** by including a special header.
+
+**Request Header**:
+```
+X-Authorization-Reset: true
+```
+
+**Note**: The value can be `true`, `"true"`, `1`, or `"1"` (boolean values are accepted in multiple formats).
+
+#### Token Refresh Process
+
+1. **Client Request**: Include `X-Authorization-Reset: true` header in any authenticated request
+2. **Server Validation**: Server validates the existing token (normal authentication flow)
+3. **Token Generation**: If validation succeeds, server generates a new token with:
+ - Same `user_id`
+ - Current `verifier_token`
+ - New `generated_at` (current timestamp)
+4. **Response Header**: New token is returned in `X-Authorization-Reset` header
+5. **Expiration Update**: New expiration time is returned in `X-Authorization-Expire` header
+
+#### Complete Token Refresh Example
+
+**Request**:
+```
+POST /user/modify
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInZlcmlmaWVyX3Rva2VuIjoiYWJjMTIzIiwiZ2VuZXJhdGVkX2F0IjoxNzA1NDQwMDAwfQ.old_signature
+X-Authorization-Reset: true
+Content-Type: application/json
+
+{
+ "first_name": "John"
+}
+```
+
+**Response** (201 Created):
+```
+HTTP/1.1 201 Created
+X-Authorization-Expire: "2025-01-23 04:36:29 +0000"
+X-Authorization-Reset: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInZlcmlmaWVyX3Rva2VuIjoiYWJjMTIzIiwiZ2VuZXJhdGVkX2F0IjoxNzA1NTIwMDAwfQ.new_signature"
+Content-Type: application/json
+
+{
+ "id": 123,
+ "first_name": "John",
+ "last_name": "Doe",
+ ...
+}
+```
+
+#### Client Implementation Steps
+
+1. **Include Refresh Header**: Add `X-Authorization-Reset: true` to request when refresh is needed
+2. **Check Response Headers**: Look for `X-Authorization-Reset` in response headers
+3. **Extract New Token**: If present, extract the new token value
+4. **Update Stored Token**: Replace stored token with new token
+5. **Use New Token**: Use new token for all subsequent requests
+
+**Example Client Implementation** (pseudo-code):
+```javascript
+async function makeAuthenticatedRequest(url, options = {}) {
+ const token = getStoredToken();
+
+ // Check if token needs refresh
+ if (shouldRefreshToken()) {
+ options.headers = options.headers || {};
+ options.headers['X-Authorization-Reset'] = 'true';
+ }
+
+ options.headers = options.headers || {};
+ options.headers['Authorization'] = `Bearer ${token}`;
+
+ const response = await fetch(url, options);
+
+ // Check for new token in response
+ const newToken = response.headers.get('X-Authorization-Reset');
+ if (newToken) {
+ storeToken(newToken);
+ }
+
+ // Update expiration time
+ const expiration = response.headers.get('X-Authorization-Expire');
+ if (expiration) {
+ updateTokenExpiration(expiration);
+ }
+
+ return response;
+}
+```
+
+#### Important Notes
+
+- **No Special Endpoint**: Token refresh doesn't require a special endpoint - it works on any authenticated endpoint
+- **Minimal Latency**: Token refresh adds minimal latency to requests
+- **Old Token Validity**: The old token remains valid until it expires, but using the new token is recommended
+- **Use Judiciously**: Only refresh when necessary to avoid unnecessary overhead
+
+### 2.7 Token Invalidation
+
+#### Verifier Token Reset Mechanism
+
+Each user has a `verifier_token` stored in the database. This token is included in every JWT token payload. When a user's `verifier_token` is reset, **all existing JWT tokens become invalid**, effectively logging the user out of all sessions.
+
+#### When Verifier Token is Reset
+
+The verifier token can be reset:
+- **By the user**: Via user settings/account management
+- **By an admin**: Via admin user management
+- **Automatically**: When security events occur (configurable)
+
+#### How to Reset Verifier Token
+
+**Endpoint**: `POST /user/modify` (for user) or `POST /admin/modify` (for admin)
+
+**Request** (User):
+```
+POST /user/modify
+Authorization: Bearer {token}
+Content-Type: application/json
+
+{
+ "verifier_token": true
+}
+```
+
+**Request** (Admin modifying another user):
+```
+POST /admin/modify
+Authorization: Bearer {admin_token}
+Content-Type: application/json
+
+{
+ "user_id": 123,
+ "verifier_token": true
+}
+```
+
+**Response** (201 Created):
+```json
+{
+ "id": 123,
+ "username": "johndoe",
+ "email": "john@example.com",
+ "verifier_token": "new_verifier_token_xyz",
+ ...
+}
+```
+
+**Note**: The `verifier_token` field accepts boolean values: `true`, `false`, `"true"`, `"false"`, `1`, `0`, `"1"`, `"0"`.
+
+#### Token Invalidation Flow
+
+1. **User/Admin Request**: User or admin requests verifier token reset
+2. **Token Generation**: System generates a new random `verifier_token`
+3. **Database Update**: User's `verifier_token` is updated in database
+4. **All Tokens Invalid**: All existing JWT tokens (which contain the old `verifier_token`) become invalid
+5. **New Login Required**: User must log in again to get a new token with the new `verifier_token`
+
+#### Use Cases
+
+- **Logout All Sessions**: User wants to log out of all devices
+- **Security Breach**: Suspected account compromise
+- **Password Change**: `POST /auth/password/change` automatically rotates `verifier_token` in the same transaction as the password digest update — all sessions (including the current one) are invalidated and the user must Sign In again. See [change_password_workflow.md](change_password_workflow.md).
+- **Admin Action**: Admin forces user to re-authenticate
+
+Manual verifier reset via `POST /user/modify` with `verifier_token: true` remains available for logout-all without a password change.
+
+---
+
+## Authorization System (RBAC)
+
+### 3.1 Overview
+
+CommandTower uses **RBAC (Role-Based Access Control)** for authorization. After a user is authenticated, the system checks if the user has the necessary role(s) to perform the requested action.
+
+**Key Principle**: Authentication answers "Who are you?" Authorization answers "What are you allowed to do?"
+
+### 3.2 Core Concepts
+
+#### Roles
+
+A **Role** is a named permission group that defines what actions a user can perform. Roles are assigned to users and checked against controller/action combinations.
+
+**Example Roles**:
+- `owner`: Full access to everything
+- `admin`: Admin panel access
+- `admin-read-only`: Read-only admin access
+- `user`: Basic user access
+
+#### Entities
+
+An **Entity** defines which controller actions a role can access. Entities map roles to specific controllers and methods.
+
+**Entity Structure**:
+- **Name**: Unique identifier for the entity
+- **Controller**: The controller class this entity applies to
+- **Only**: (Optional) List of specific actions this entity allows
+- **Except**: (Optional) List of actions this entity excludes
+
+**Example Entity**:
+```yaml
+- name: admin
+ controller: CommandTower::AdminController
+ # No 'only' or 'except' means all actions are allowed
+```
+
+```yaml
+- name: admin-read-only
+ controller: CommandTower::AdminController
+ only: [show] # Only 'show' action is allowed
+```
+
+```yaml
+- name: admin-without-impersonate
+ controller: CommandTower::AdminController
+ except: [impersonate] # All actions except 'impersonate'
+```
+
+#### Role-Entity Relationships
+
+Roles contain one or more entities. When checking authorization:
+1. System finds all roles assigned to the user
+2. For each role, checks if any of its entities match the requested controller/action
+3. If at least one role's entity matches and authorizes the action, access is granted
+
+### 3.3 Authorization Requirements
+
+#### When Authorization is Required
+
+Authorization is required for any endpoint that uses the `before_action :authorize_user!` callback.
+
+**Important**: Authorization **must** come **after** authentication:
+
+```ruby
+class MyController < CommandTower::ApplicationController
+ before_action :authenticate_user! # Must come first
+ before_action :authorize_user! # Must come after authentication
+end
+```
+
+#### Routes That Require Authorization
+
+By default, authorization is required for:
+- Admin endpoints (`/admin/*`)
+- Message blast endpoints (`/inbox/blast/*`)
+- Any custom endpoints that include `authorize_user!`
+
+#### Authorization Check Process
+
+1. **Authentication First**: `authenticate_user!` must succeed and set `current_user`
+2. **Route Mapping Check**: System checks if the controller/action combination requires authorization
+3. **Role Matching**: System checks if `current_user.roles` includes any role that authorizes this action
+4. **Entity Matching**: For each matching role, checks if any entity matches the controller/action
+5. **Authorization Result**: If at least one role authorizes the action, access is granted; otherwise, `403 Forbidden` is returned
+
+### 3.4 Role Definitions
+
+#### Default Roles
+
+CommandTower includes several default roles defined in `lib/command_tower/authorization/default.yml`:
+
+**1. `owner`**
+- **Description**: The owner of the application will have full access to all components
+- **Special Property**: `allow_everything: true` - bypasses all authorization checks
+- **Use Case**: Super admin or application owner
+
+**2. `admin`**
+- **Description**: Full admin read and write operations. Can view and update other users' states.
+- **Entities**:
+ - `admin` (all AdminController actions)
+ - `message-blast` (all MessageBlastController actions)
+- **Use Case**: Full administrative access
+
+**3. `admin-without-impersonation`**
+- **Description**: Admin read and write operations, but impersonation is not permitted
+- **Entities**:
+ - `admin-without-impersonate` (AdminController except `impersonate` action)
+ - `message-blast` (all MessageBlastController actions)
+- **Use Case**: Admin users who shouldn't be able to impersonate others
+
+**4. `admin-read-only`**
+- **Description**: Admin read interface only
+- **Entities**:
+ - `read-admin` (AdminController `show` action only)
+ - `message-blast-read-only` (MessageBlastController `metadata` action only)
+- **Use Case**: Read-only admin access for auditing or reporting
+
+#### Custom Role Creation via YAML
+
+You can define custom roles in a YAML file (default: `config/rbac_groups.yml`).
+
+**Configuration**:
+```ruby
+# config/initializers/command_tower.rb
+CommandTower.config.authorization.rbac_group_path = Rails.root.join("config", "custom_rbac.yml")
+```
+
+**YAML Structure**:
+```yaml
+groups:
+ my-custom-role:
+ description: "Description of what this role allows"
+ entities:
+ - entity-name-1
+ - entity-name-2
+
+entities:
+ - name: entity-name-1
+ controller: MyApp::MyController
+ only: [index, show] # Optional: only these actions
+
+ - name: entity-name-2
+ controller: MyApp::AnotherController
+ except: [delete] # Optional: all actions except these
+```
+
+**Example Custom Role**:
+```yaml
+groups:
+ content-manager:
+ description: "Can manage content but not users"
+ entities:
+ - content-admin
+ - content-read
+
+entities:
+ - name: content-admin
+ controller: MyApp::ContentController
+ except: [delete]
+
+ - name: content-read
+ controller: MyApp::ContentController
+ only: [index, show]
+```
+
+#### Custom Role Creation via Code
+
+For complex authorization logic, you can define roles and entities programmatically:
+
+```ruby
+# In an initializer or config file
+
+# 1. Create Entity
+entity = CommandTower::Authorization::Entity.create_entity(
+ name: "custom-entity",
+ controller: MyApp::MyController,
+ only: [:index, :show]
+)
+
+# 2. Create Role
+role = CommandTower::Authorization::Role.create_role(
+ name: "custom-role",
+ description: "Custom role description",
+ entities: [entity],
+ allow_everything: false
+)
+```
+
+**Custom Entity Authorization**:
+
+You can create custom entity classes with custom authorization logic:
+
+```ruby
+class CustomEntity < CommandTower::Authorization::Entity
+ def authorized?(user:)
+ # Custom logic here
+ # Return true if user is authorized, false otherwise
+ user.some_custom_attribute == "allowed_value"
+ end
+end
+
+entity = CustomEntity.create_entity(
+ name: "custom-entity",
+ controller: MyApp::MyController
+)
+```
+
+### 3.5 Entity Definitions
+
+#### Entity Structure
+
+Entities define which controller actions a role can access:
+
+**Required Fields**:
+- `name` (String): Unique identifier for the entity
+- `controller` (Class or String): The controller class this entity applies to
+
+**Optional Fields**:
+- `only` (Array of Symbols): List of specific actions this entity allows
+- `except` (Array of Symbols): List of actions this entity excludes
+
+**Constraints**:
+- `only` and `except` cannot both be specified
+- If neither is specified, all actions on the controller are allowed
+
+#### Entity Matching Logic
+
+When checking if an entity matches a request:
+
+1. **Controller Match**: Entity's controller must match the request's controller
+2. **Action Match**:
+ - If `only` is specified: Action must be in the `only` list
+ - If `except` is specified: Action must NOT be in the `except` list
+ - If neither is specified: All actions match
+
+**Example**:
+```yaml
+- name: read-only-admin
+ controller: CommandTower::AdminController
+ only: [show, index]
+```
+
+This entity matches:
+- `AdminController#show` ✓
+- `AdminController#index` ✓
+- `AdminController#modify` ✗ (not in `only` list)
+
+### 3.6 Authorization Validation Process
+
+#### Step-by-Step Process
+
+When `authorize_user!` is called:
+
+1. **Check Current User**: Verifies `current_user` is set (from authentication)
+2. **Check Authorization Required**: Determines if the controller/action requires authorization
+3. **Get User Roles**: Retrieves all roles assigned to `current_user`
+4. **Find Matching Roles**: Finds all Role objects that match the user's role names
+5. **Check Each Role**: For each matching role:
+ - If role has `allow_everything: true`, authorization is granted
+ - Otherwise, checks if any entity in the role matches the controller/action
+ - For matching entities, calls `entity.authorized?(user:)` for custom logic
+6. **Authorization Result**: If at least one role authorizes the action, access is granted
+
+#### Code Flow
+
+```ruby
+# In CommandTower::Authorize::Validate
+def call
+ # 1. Check if authorization is required for this route
+ return unless authorization_required?
+
+ # 2. Get user's role objects
+ user_role_objects = CommandTower::Authorization::Role.roles.select do |role_name, _|
+ user.roles.include?(role_name.to_s)
+ end
+
+ # 3. Check if any role authorizes the action
+ authorization_result = user_role_objects.any? do |_role_name, role_object|
+ result = role_object.authorized?(controller:, method:, user:)
+ result[:authorized] == true
+ end
+
+ # 4. Fail if not authorized
+ context.fail!(msg: "Unauthorized Access. Incorrect User Privileges") unless authorization_result
+end
+```
+
+#### Role Authorization Check
+
+```ruby
+# In CommandTower::Authorization::Role
+def authorized?(controller:, method:, user:)
+ # 1. Check if role allows everything
+ return { authorized: true } if allow_everything
+
+ # 2. Find entities that match this controller
+ matched_controllers = controller_entity_mapping[controller]
+ return { authorized: nil } if matched_controllers.nil?
+
+ # 3. Check each entity
+ rejected_entities = matched_controllers.map do |entity|
+ case entity.matches?(controller:, method:)
+ when true
+ # Entity matches, check custom authorization
+ entity.authorized?(user:) ? nil : { authorized: false, ... }
+ when false, nil
+ { authorized: false, ... }
+ end
+ end.compact
+
+ # 4. Return result
+ rejected_entities.empty? ? { authorized: true } : { authorized: false }
+end
+```
+
+### 3.7 Authorization Failure (403 Forbidden)
+
+#### When 403 is Returned
+
+A `403 Forbidden` status is returned when:
+- User is **authenticated** (has valid token)
+- User **lacks required role(s)** for the requested action
+- User's roles don't match any role that authorizes the controller/action combination
+
+#### Error Response Format
+
+**Response** (403 Forbidden):
+```json
+{
+ "status": "403",
+ "message": "Unauthorized Access. Incorrect User Privileges"
+}
+```
+
+**Response Schema**:
+- `status` (String, required): HTTP status code as string ("403")
+- `message` (String, required): Error message describing the authorization failure
+
+#### Example Scenarios
+
+**Scenario 1: User Without Admin Role Tries to Access Admin Endpoint**
+
+**Request**:
+```
+GET /admin/
+Authorization: Bearer {user_token}
+```
+
+**User's Roles**: `["user"]`
+
+**Response** (403 Forbidden):
+```json
+{
+ "status": "403",
+ "message": "Unauthorized Access. Incorrect User Privileges"
+}
+```
+
+**Scenario 2: Read-Only Admin Tries to Modify User**
+
+**Request**:
+```
+POST /admin/modify
+Authorization: Bearer {read_only_admin_token}
+Content-Type: application/json
+
+{
+ "user_id": 123,
+ "email": "newemail@example.com"
+}
+```
+
+**User's Roles**: `["admin-read-only"]`
+
+**Response** (403 Forbidden):
+```json
+{
+ "status": "403",
+ "message": "Unauthorized Access. Incorrect User Privileges"
+}
+```
+
+**Reason**: `admin-read-only` role only allows `show` action on `AdminController`, not `modify`.
+
+**Scenario 3: Admin Without Impersonation Tries to Impersonate**
+
+**Request**:
+```
+POST /admin/impersonate
+Authorization: Bearer {admin_without_impersonation_token}
+Content-Type: application/json
+
+{
+ "user_id": 123
+}
+```
+
+**User's Roles**: `["admin-without-impersonation"]`
+
+**Response** (403 Forbidden):
+```json
+{
+ "status": "403",
+ "message": "Unauthorized Access. Incorrect User Privileges"
+}
+```
+
+**Reason**: `admin-without-impersonation` role explicitly excludes the `impersonate` action.
+
+#### Debugging Authorization Failures
+
+To debug why authorization failed:
+
+1. **Check User Roles**: Verify what roles are assigned to the user
+2. **Check Role Definitions**: Verify what entities each role contains
+3. **Check Entity Matching**: Verify if entities match the controller/action
+4. **Check Custom Authorization**: If entities have custom `authorized?` methods, check their logic
+
+**Example Debug Flow**:
+```ruby
+# In Rails console
+user = User.find(123)
+puts "User roles: #{user.roles}"
+
+role = CommandTower::Authorization::Role.roles["admin"]
+puts "Role entities: #{role.entities.map(&:name)}"
+
+entity = role.entities.first
+result = entity.matches?(controller: CommandTower::AdminController, method: "modify")
+puts "Entity matches: #{result}"
+
+auth_result = role.authorized?(
+ controller: CommandTower::AdminController,
+ method: "modify",
+ user: user
+)
+puts "Authorization result: #{auth_result}"
+```
+
+---
+
+## Integration Guide
+
+### 4.1 Setting Up Authentication
+
+#### Controller Setup
+
+To require authentication on a controller:
+
+```ruby
+class MyController < CommandTower::ApplicationController
+ before_action :authenticate_user!
+
+ def my_action
+ # current_user is available here
+ render json: { message: "Hello, #{current_user.username}" }
+ end
+end
+```
+
+#### Route Configuration
+
+Routes are automatically configured when you mount the CommandTower engine. Authentication is handled at the controller level via `before_action`.
+
+#### Error Handling
+
+Authentication failures automatically return `401 Unauthorized` responses. You don't need to handle this manually - the `authenticate_user!` method handles it.
+
+**Example Error Response**:
+```json
+{
+ "status": "401",
+ "message": "Bearer token missing"
+}
+```
+
+### 4.2 Setting Up Authorization
+
+#### Controller Setup
+
+To require authorization on a controller:
+
+```ruby
+class MyController < CommandTower::ApplicationController
+ # Order is important: authentication must come before authorization
+ before_action :authenticate_user!
+ before_action :authorize_user!
+
+ def my_action
+ # User is authenticated AND authorized here
+ render json: { message: "Authorized action" }
+ end
+end
+```
+
+#### Defining Custom Roles
+
+**Option 1: YAML Configuration**
+
+Create `config/rbac_groups.yml`:
+
+```yaml
+groups:
+ content-manager:
+ description: "Can manage content"
+ entities:
+ - content-entity
+
+entities:
+ - name: content-entity
+ controller: MyApp::ContentController
+ except: [delete]
+```
+
+**Option 2: Code Configuration**
+
+In an initializer:
+
+```ruby
+# config/initializers/command_tower_roles.rb
+
+# Create entity
+entity = CommandTower::Authorization::Entity.create_entity(
+ name: "content-entity",
+ controller: MyApp::ContentController,
+ except: [:delete]
+)
+
+# Create role
+CommandTower::Authorization::Role.create_role(
+ name: "content-manager",
+ description: "Can manage content",
+ entities: [entity]
+)
+```
+
+#### Assigning Roles to Users
+
+Roles are stored as an array on the User model. You can assign roles:
+
+```ruby
+# In Rails console or service
+user = User.find(123)
+user.roles = ["content-manager", "user"]
+user.save!
+```
+
+Or via admin endpoint:
+
+```
+POST /admin/modify/role
+Authorization: Bearer {admin_token}
+Content-Type: application/json
+
+{
+ "user_id": 123,
+ "roles": ["content-manager", "user"]
+}
+```
+
+### 4.3 Client Implementation
+
+#### Token Storage
+
+**Best Practices**:
+- **Web Applications**: Use secure HTTP-only cookies (most secure)
+- **Mobile Applications**: Use secure keychain/keystore
+- **Never**: Store tokens in localStorage (vulnerable to XSS)
+
+**Example (React with secure storage)**:
+```javascript
+// Store token
+function storeToken(token) {
+ // Use secure storage mechanism
+ SecureStore.setItemAsync('auth_token', token);
+}
+
+// Retrieve token
+async function getToken() {
+ return await SecureStore.getItemAsync('auth_token');
+}
+```
+
+#### Token Refresh Implementation
+
+**Recommended Strategy**:
+
+1. **Monitor Expiration**: Check `X-Authorization-Expire` header on every response
+2. **Refresh Proactively**: Refresh when less than 5 minutes remain
+3. **Handle Errors**: If refresh fails, redirect to login
+
+**Example Implementation**:
+```javascript
+class AuthService {
+ constructor() {
+ this.token = null;
+ this.expirationTime = null;
+ }
+
+ async makeRequest(url, options = {}) {
+ // Check if token needs refresh
+ if (this.shouldRefreshToken()) {
+ options.headers = options.headers || {};
+ options.headers['X-Authorization-Reset'] = 'true';
+ }
+
+ // Add authorization header
+ if (this.token) {
+ options.headers = options.headers || {};
+ options.headers['Authorization'] = `Bearer ${this.token}`;
+ }
+
+ const response = await fetch(url, options);
+
+ // Update token if refreshed
+ const newToken = response.headers.get('X-Authorization-Reset');
+ if (newToken) {
+ this.token = newToken;
+ this.storeToken(newToken);
+ }
+
+ // Update expiration
+ const expiration = response.headers.get('X-Authorization-Expire');
+ if (expiration) {
+ this.expirationTime = new Date(expiration);
+ }
+
+ return response;
+ }
+
+ shouldRefreshToken() {
+ if (!this.expirationTime) return false;
+
+ const now = new Date();
+ const timeRemaining = this.expirationTime - now;
+ const fiveMinutes = 5 * 60 * 1000;
+
+ return timeRemaining < fiveMinutes;
+ }
+
+ storeToken(token) {
+ // Implement secure storage
+ localStorage.setItem('auth_token', token); // Not recommended for production
+ }
+}
+```
+
+#### Handling 401 Errors
+
+When a `401 Unauthorized` is received:
+
+1. **Clear Stored Token**: Remove invalid token from storage
+2. **Redirect to Login**: Redirect user to login page
+3. **Show Error Message**: Inform user that their session expired
+
+**Example**:
+```javascript
+async function handleResponse(response) {
+ if (response.status === 401) {
+ // Clear token
+ await clearStoredToken();
+
+ // Redirect to login
+ window.location.href = '/login';
+
+ // Show error
+ showError('Your session has expired. Please log in again.');
+ }
+
+ return response;
+}
+```
+
+#### Handling 403 Errors
+
+When a `403 Forbidden` is received:
+
+1. **Show Error Message**: Inform user they don't have permission
+2. **Redirect if Appropriate**: Redirect to a page they can access
+3. **Log for Admin**: Log the authorization failure for admin review
+
+**Example**:
+```javascript
+async function handleResponse(response) {
+ if (response.status === 403) {
+ const error = await response.json();
+
+ // Show error
+ showError(error.message || 'You do not have permission to perform this action.');
+
+ // Optionally redirect
+ // window.location.href = '/dashboard';
+ }
+
+ return response;
+}
+```
+
+---
+
+## Configuration
+
+### 5.1 JWT Configuration
+
+JWT configuration is done in the CommandTower initializer:
+
+```ruby
+# config/initializers/command_tower.rb
+
+CommandTower.configure do |config|
+ # JWT Configuration
+ config.jwt.ttl = 7.days # Default: 7.days
+ config.jwt.hmac_secret = ENV['SECRET_KEY_BASE'] # Default: ENV['SECRET_KEY_BASE']
+end
+```
+
+#### Configuration Options
+
+**`config.jwt.ttl`**
+- **Type**: `ActiveSupport::Duration`
+- **Default**: `7.days`
+- **Description**: How long JWT tokens remain valid
+- **Example**: `config.jwt.ttl = 24.hours`
+
+**`config.jwt.hmac_secret`**
+- **Type**: `String`
+- **Default**: `ENV['SECRET_KEY_BASE']` or fallback secret
+- **Description**: Secret key used to sign and verify JWT tokens
+- **Security**: Should be a strong, random secret (use `ENV['SECRET_KEY_BASE']` in production)
+
+### 5.2 Authorization Configuration
+
+Authorization configuration is done in the CommandTower initializer:
+
+```ruby
+# config/initializers/command_tower.rb
+
+CommandTower.configure do |config|
+ # Authorization Configuration
+ config.authorization.rbac_default_groups = true # Default: true
+ config.authorization.rbac_group_path = Rails.root.join("config", "rbac_groups.yml") # Default
+end
+```
+
+#### Configuration Options
+
+**`config.authorization.rbac_default_groups`**
+- **Type**: `Boolean`
+- **Default**: `true`
+- **Description**: Whether to load default roles (`owner`, `admin`, etc.)
+- **Recommendation**: Keep as `true` unless you want to define all roles yourself
+
+**`config.authorization.rbac_group_path`**
+- **Type**: `String` (file path)
+- **Default**: `Rails.root.join("config", "rbac_groups.yml")`
+- **Description**: Path to YAML file containing custom role definitions
+- **Example**: `config.authorization.rbac_group_path = Rails.root.join("config", "custom_roles.yml")`
+
+---
+
+## Security Considerations
+
+### 6.1 Token Security
+
+#### Secure Token Storage
+
+**Web Applications**:
+- Use **HTTP-only cookies** (prevents XSS attacks)
+- Set `Secure` flag (HTTPS only)
+- Set `SameSite` attribute appropriately
+
+**Mobile Applications**:
+- Use **secure keychain/keystore** (iOS Keychain, Android Keystore)
+- Never store in plain text files
+- Use platform-specific secure storage APIs
+
+**Never**:
+- Store tokens in `localStorage` (vulnerable to XSS)
+- Store tokens in `sessionStorage` (vulnerable to XSS)
+- Log tokens in client-side code
+- Include tokens in URLs
+
+#### Token Transmission
+
+- **Always use HTTPS** in production
+- Never send tokens over unencrypted connections
+- Use secure headers (`Strict-Transport-Security`)
+
+#### Token Expiration
+
+- **Set appropriate TTL**: Balance security (shorter) vs. user experience (longer)
+- **Monitor expiration**: Refresh tokens before expiration
+- **Handle expiration gracefully**: Redirect to login when tokens expire
+
+#### Token Invalidation
+
+- **Reset verifier_token** when:
+ - User logs out
+ - Password is changed
+ - Security breach is suspected
+ - Admin forces re-authentication
+
+### 6.2 Authorization Security
+
+#### Role Assignment Security
+
+- **Principle of Least Privilege**: Assign minimum necessary roles
+- **Regular Audits**: Review user roles periodically
+- **Admin-Only Assignment**: Only allow admins to assign roles
+- **Audit Logging**: Log all role changes
+
+#### Entity Definition Security
+
+- **Validate Controllers**: Ensure entity controllers exist and are correct
+- **Test Authorization**: Test authorization logic thoroughly
+- **Document Roles**: Document what each role allows
+- **Review Custom Logic**: Review custom `authorized?` methods for security issues
+
+#### Principle of Least Privilege
+
+- **Default Deny**: By default, users should have no special permissions
+- **Explicit Allow**: Explicitly grant permissions via roles
+- **Regular Review**: Periodically review and remove unnecessary permissions
+
+---
+
+## Troubleshooting
+
+### 7.1 Common Authentication Issues
+
+#### Issue: "Bearer token missing"
+
+**Symptoms**: `401 Unauthorized` with message "Bearer token missing"
+
+**Causes**:
+- Request doesn't include `Authorization` header
+- Header is empty or nil
+
+**Solutions**:
+1. Ensure request includes `Authorization: Bearer {token}` header
+2. Check that token is being sent correctly from client
+3. Verify token is stored and retrieved correctly
+
+#### Issue: "Invalid Bearer token format"
+
+**Symptoms**: `401 Unauthorized` with message "Invalid Bearer token format"
+
+**Causes**:
+- Header format is incorrect
+- Missing space between "Bearer" and token
+- Case sensitivity issues
+
+**Solutions**:
+1. Ensure header format is exactly: `Authorization: Bearer {token}`
+2. Check for extra spaces or missing spaces
+3. Verify "Bearer" is capitalized correctly
+
+#### Issue: "Unauthorized Access. Invalid Authorization token"
+
+**Symptoms**: `401 Unauthorized` with message "Unauthorized Access. Invalid Authorization token"
+
+**Causes**:
+- Token is expired
+- Token cannot be decoded (invalid signature)
+- Token payload is malformed
+- User ID in token doesn't exist
+
+**Solutions**:
+1. Check token expiration: Verify `X-Authorization-Expire` header
+2. Verify token wasn't tampered with
+3. Ensure user still exists in database
+4. Request new token via login
+
+#### Issue: "Unauthorized Access. Token is no longer valid"
+
+**Symptoms**: `401 Unauthorized` with message "Unauthorized Access. Token is no longer valid"
+
+**Causes**:
+- User's `verifier_token` was reset
+- Token contains old `verifier_token` that no longer matches
+
+**Solutions**:
+1. User must log in again to get new token
+2. Check if verifier_token was reset (intentionally or accidentally)
+3. Verify user account is still active
+
+#### Issue: "Email must be verified to continue"
+
+**Symptoms**: `412 Precondition Failed` with message about email validation
+
+**Causes**:
+- Email verification is enabled
+- User's email is not validated
+
+**Solutions**:
+1. User must verify email via `/auth/email/verify` endpoint
+2. Or use `authenticate_user_without_email_verification!` for specific endpoints
+
+### 7.2 Common Authorization Issues
+
+#### Issue: 403 Forbidden on Authorized Route
+
+**Symptoms**: `403 Forbidden` when user should have access
+
+**Causes**:
+- User doesn't have required role
+- Role doesn't include entity for this controller/action
+- Entity doesn't match the controller/action
+
+**Solutions**:
+1. Check user's roles: `user.roles`
+2. Verify role definitions include correct entities
+3. Check entity matches controller and action
+4. Review custom authorization logic if applicable
+
+**Debug Steps**:
+```ruby
+# In Rails console
+user = User.find(123)
+puts "User roles: #{user.roles.inspect}"
+
+role = CommandTower::Authorization::Role.roles["admin"]
+puts "Role entities: #{role.entities.map(&:name)}"
+
+# Check if route requires authorization
+controller = CommandTower::AdminController
+action = "modify"
+mapped = CommandTower::Authorization.mapped_controllers[controller]
+puts "Route requires authorization: #{mapped&.include?(action.to_sym)}"
+
+# Check authorization result
+result = CommandTower::Authorize::Validate.(
+ user: user,
+ controller: controller,
+ method: action
+)
+puts "Authorization result: #{result.success?} - #{result.msg}"
+```
+
+#### Issue: Authorization Not Being Checked
+
+**Symptoms**: User can access route without proper role
+
+**Causes**:
+- `authorize_user!` not called in controller
+- Route not mapped for authorization
+- User has `owner` role (bypasses all checks)
+
+**Solutions**:
+1. Ensure `before_action :authorize_user!` is in controller
+2. Verify route is mapped for authorization
+3. Check if user has `owner` role (which bypasses checks)
+
+---
+
+## Examples and Use Cases
+
+### 8.1 Complete Authentication Flow
+
+**Step 1: User Registration**
+
+```
+POST /auth/create
+Content-Type: application/json
+
+{
+ "first_name": "John",
+ "last_name": "Doe",
+ "username": "johndoe",
+ "email": "john@example.com",
+ "password": "securepassword123",
+ "password_confirmation": "securepassword123"
+}
+```
+
+**Response** (201 Created):
+```json
+{
+ "full_name": "John Doe",
+ "first_name": "John",
+ "last_name": "Doe",
+ "username": "johndoe",
+ "email": "john@example.com",
+ "msg": "Successfully created new User"
+}
+```
+
+**Step 2: User Login**
+
+```
+POST /auth/login
+Content-Type: application/json
+
+{
+ "identifier": "johndoe",
+ "password": "securepassword123"
+}
+```
+
+**Response** (201 Created):
+```json
+{
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+ "header_name": "Authorization",
+ "message": "Successfully logged user in",
+ "user": { ... }
+}
+```
+
+**Step 3: Make Authenticated Request**
+
+```
+GET /user/
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
+```
+
+**Response** (200 OK):
+```
+X-Authorization-Expire: "2025-01-16 04:36:29 +0000"
+Content-Type: application/json
+
+{
+ "id": 123,
+ "username": "johndoe",
+ ...
+}
+```
+
+### 8.2 Complete Authorization Flow
+
+**Scenario**: Admin user accessing admin endpoint
+
+**Step 1: Login as Admin**
+
+```
+POST /auth/login
+Content-Type: application/json
+
+{
+ "identifier": "admin",
+ "password": "adminpassword"
+}
+```
+
+**Response**: Returns token for user with `roles: ["admin"]`
+
+**Step 2: Access Admin Endpoint**
+
+```
+GET /admin/
+Authorization: Bearer {admin_token}
+```
+
+**Authorization Check**:
+1. ✅ Authentication succeeds (valid token)
+2. ✅ `current_user` is set
+3. ✅ Route requires authorization (`/admin/` is mapped)
+4. ✅ User has `admin` role
+5. ✅ `admin` role includes `admin` entity
+6. ✅ `admin` entity matches `AdminController#show`
+7. ✅ Authorization succeeds
+
+**Response** (200 OK):
+```json
+{
+ "id": 1,
+ "username": "admin",
+ ...
+}
+```
+
+**Step 3: Non-Admin User Tries Same Endpoint**
+
+```
+GET /admin/
+Authorization: Bearer {user_token}
+```
+
+**Authorization Check**:
+1. ✅ Authentication succeeds (valid token)
+2. ✅ `current_user` is set
+3. ✅ Route requires authorization
+4. ❌ User has `["user"]` role, not `admin`
+5. ❌ No matching role authorizes this action
+6. ❌ Authorization fails
+
+**Response** (403 Forbidden):
+```json
+{
+ "status": "403",
+ "message": "Unauthorized Access. Incorrect User Privileges"
+}
+```
+
+### 8.3 Token Refresh Flow
+
+**Scenario**: Token is about to expire, client refreshes it
+
+**Step 1: Make Request with Refresh Header**
+
+```
+POST /user/modify
+Authorization: Bearer {old_token}
+X-Authorization-Reset: true
+Content-Type: application/json
+
+{
+ "first_name": "John Updated"
+}
+```
+
+**Step 2: Server Response with New Token**
+
+**Response** (201 Created):
+```
+X-Authorization-Expire: "2025-01-23 04:36:29 +0000"
+X-Authorization-Reset: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.{new_token_payload}.{new_signature}"
+Content-Type: application/json
+
+{
+ "id": 123,
+ "first_name": "John Updated",
+ ...
+}
+```
+
+**Step 3: Client Updates Token**
+
+Client extracts new token from `X-Authorization-Reset` header and stores it for future requests.
+
+**Step 4: Use New Token**
+
+All subsequent requests use the new token from `X-Authorization-Reset`.
+
+### 8.4 Custom Role Implementation
+
+**Scenario**: Create a custom "content-manager" role
+
+**Step 1: Define Entity and Role in YAML**
+
+Create `config/rbac_groups.yml`:
+
+```yaml
+groups:
+ content-manager:
+ description: "Can manage content but not delete"
+ entities:
+ - content-entity
+
+entities:
+ - name: content-entity
+ controller: MyApp::ContentController
+ except: [delete]
+```
+
+**Step 2: Assign Role to User**
+
+```
+POST /admin/modify/role
+Authorization: Bearer {admin_token}
+Content-Type: application/json
+
+{
+ "user_id": 123,
+ "roles": ["content-manager", "user"]
+}
+```
+
+**Step 3: User Accesses Content Endpoint**
+
+```
+GET /content/
+Authorization: Bearer {content_manager_token}
+```
+
+**Authorization Check**:
+1. ✅ Authentication succeeds
+2. ✅ User has `content-manager` role
+3. ✅ `content-manager` role includes `content-entity`
+4. ✅ `content-entity` matches `ContentController#index` (not in `except` list)
+5. ✅ Authorization succeeds
+
+**Step 4: User Tries to Delete Content**
+
+```
+DELETE /content/456
+Authorization: Bearer {content_manager_token}
+```
+
+**Authorization Check**:
+1. ✅ Authentication succeeds
+2. ✅ User has `content-manager` role
+3. ✅ `content-manager` role includes `content-entity`
+4. ❌ `content-entity` does NOT match `ContentController#delete` (in `except` list)
+5. ❌ Authorization fails
+
+**Response** (403 Forbidden):
+```json
+{
+ "status": "403",
+ "message": "Unauthorized Access. Incorrect User Privileges"
+}
+```
+
+---
+
+## API Reference
+
+### 9.1 Authentication Headers
+
+#### Request Headers
+
+**`Authorization`** (Required for authenticated endpoints)
+- **Format**: `Bearer {token}`
+- **Example**: `Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...`
+- **Purpose**: Identifies the authenticated user
+
+**`X-Authorization-Reset`** (Optional)
+- **Format**: `true`, `"true"`, `1`, or `"1"`
+- **Example**: `X-Authorization-Reset: true`
+- **Purpose**: Requests token refresh
+
+#### Response Headers
+
+**`X-Authorization-Expire`** (All authenticated requests)
+- **Format**: ISO 8601 timestamp string
+- **Example**: `X-Authorization-Expire: "2025-01-16 04:36:29 +0000"`
+- **Purpose**: Indicates when the current token will expire
+
+**`X-Authorization-Reset`** (Only when refresh requested)
+- **Format**: JWT token string
+- **Example**: `X-Authorization-Reset: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...`
+- **Purpose**: Contains the new token when refresh is requested
+
+### 9.2 Authorization Headers
+
+Authorization uses the same `Authorization` header as authentication. The authorization check occurs after authentication succeeds.
+
+### 9.3 Response Headers Summary
+
+| Header | Request/Response | When Present | Purpose |
+|--------|------------------|--------------|---------|
+| `Authorization` | Request | All authenticated endpoints | Bearer token for authentication |
+| `X-Authorization-Reset` | Request | When token refresh needed | Requests token refresh |
+| `X-Authorization-Expire` | Response | All authenticated requests | Token expiration timestamp |
+| `X-Authorization-Reset` | Response | When refresh requested | New token value |
+
+### 9.4 Error Response Formats
+
+#### Authentication Errors (401)
+
+**Format**:
+```json
+{
+ "status": "401",
+ "message": "Error message describing the issue"
+}
+```
+
+**Common Messages**:
+- `"Bearer token missing"`
+- `"Invalid Bearer token format"`
+- `"Unauthorized Access. Invalid Authorization token"`
+- `"Unauthorized Access. Token is no longer valid"`
+
+#### Email Validation Errors (412)
+
+**Format**:
+```json
+{
+ "status": "412",
+ "message": "Email must be verified to continue",
+ "meta": {
+ "email_validated": false
+ }
+}
+```
+
+**Common Messages**:
+- `"Email must be verified to continue"`
+
+#### Authorization Errors (403)
+
+**Format**:
+```json
+{
+ "status": "403",
+ "message": "Unauthorized Access. Incorrect User Privileges"
+}
+```
+
+#### Validation Errors (400)
+
+**Format**:
+```json
+{
+ "status": "400",
+ "message": "Error message",
+ "invalid_arguments": {
+ "field_name": ["Error message for field"]
+ }
+}
+```
+
+**Example**:
+```json
+{
+ "status": "400",
+ "message": "Invalid arguments provided",
+ "invalid_arguments": {
+ "email": ["Invalid email address"],
+ "password": ["Parameter [password] is required but not present"]
+ }
+}
+```
+
+---
+
+## Conclusion
+
+This guide has covered the complete authentication and authorization system in CommandTower. Key takeaways:
+
+1. **Authentication** uses JWT tokens and validates user identity
+2. **Authorization** uses RBAC to check user permissions
+3. **Tokens** expire based on TTL configuration and can be refreshed
+4. **Roles** define what users can do, **Entities** define which actions roles allow
+5. **401** means authentication failed, **403** means authorization failed
+
+For additional information, refer to:
+- [API Reference](api_reference.md)
+- [Initialization Guide](initializing.md)
+- [Models Documentation](models.md)
diff --git a/docs/change_password_workflow.md b/docs/change_password_workflow.md
new file mode 100644
index 0000000..2a1900f
--- /dev/null
+++ b/docs/change_password_workflow.md
@@ -0,0 +1,74 @@
+# Authenticated Password Change Workflow
+
+## Overview
+
+Authenticated password change lets a signed-in user replace their password by proving knowledge of the current password. On success, CommandTower rotates `verifier_token` in the same database transaction as the password digest update, invalidating **all** existing JWT sessions (including the caller’s). The API does **not** re-issue a JWT; hosts must clear local auth and send the user through Sign In again.
+
+This is distinct from [password recovery](password_reset_workflow.md) (`POST /auth/password/forgot/*`), which is public and token-based and does **not** rotate the verifier today.
+
+## Endpoint
+
+| Item | Value |
+|------|--------|
+| Route | `POST /auth/password/change` |
+| Auth | JWT required (`authenticate_user!`) |
+| Controller | `CommandTower::Auth::PlainTextController#password_change_post` |
+| Service | `CommandTower::LoginStrategy::PlainText::ChangePassword` |
+| Feature gate | `CommandTower.config.login.plain_text.enable?` (not gated by `password_reset?`) |
+
+### Why this controller
+
+Password change lives on `Auth::PlainTextController` next to other plain-text auth flows (login, create, email verify, forgot/reset). Selective `before_action :authenticate_user!` mirrors email-verify’s selective JWT pattern, while keeping the route under `/auth/password/*`. Account attribute edits remain on `UserController` (`/user/modify`).
+
+## Request
+
+```json
+{
+ "current_password": "string",
+ "password": "string",
+ "password_confirmation": "string"
+}
+```
+
+## Success response (200)
+
+```json
+{
+ "message": "Password has been successfully changed"
+}
+```
+
+No JWT, no verifier, and no password material are returned.
+
+## Service flow
+
+1. Authenticate `current_password` via `user.authenticate`
+2. Confirm `password` matches `password_confirmation`
+3. Enforce `password_length_min` / `password_length_max` from plain_text config
+4. In one DB transaction: save new password → `reset_verifier_token!`
+5. On txn failure: roll back both ops; surface typed CT failures (`invalid_arguments` for validation/persist errors, `status: 500` for verifier infrastructure failure)
+6. Set minimal success message only
+
+## Failures (HTTP)
+
+| Condition | Status | Shape |
+|-----------|--------|--------|
+| Missing / invalid JWT | 401 | Existing unauthenticated shared behavior |
+| Bad current password, confirmation, length, model errors | 400 | `InvalidArguments` schema (same pattern as `/user/modify` and forgot reset) |
+| Verifier rotation / unexpected infra failure | 500 | `Schema::Error::Base` |
+
+## Contrast with recovery reset
+
+| | Authenticated change | Forgot / reset |
+|--|----------------------|----------------|
+| Proof | Current password | UserSecret token |
+| Route | `POST /auth/password/change` | `POST /auth/password/forgot/reset` |
+| Auth | JWT | Public |
+| Verifier | Rotated (required) | Not rotated (known gap) |
+| Post-success session | All JWTs invalid; host → Sign In | Host-defined |
+
+## Security notes
+
+- Never log passwords, verifier values, or raw request bodies containing secrets.
+- Success deliberately does **not** preserve the current session.
+- Hosts must treat success as a forced re-login.
diff --git a/docs/cookie_authentication_guide.md b/docs/cookie_authentication_guide.md
new file mode 100644
index 0000000..bb8a0db
--- /dev/null
+++ b/docs/cookie_authentication_guide.md
@@ -0,0 +1,955 @@
+# Cookie Authentication Guide for Web Applications
+
+This guide explains when and how to use HttpOnly cookie-based JWT authentication in CommandTower, particularly for web applications that need browser-based session persistence.
+
+## Cookie JWT Auth Overview
+
+### What It Does
+
+Cookie-based JWT authentication enables persistence across web reloads without storing JWT tokens in localStorage or sessionStorage. This provides a secure, automatic way to maintain user sessions in browser-based applications.
+
+### How It Works
+
+The authentication system uses a **header-first, cookie fallback** approach:
+
+1. **Authorization Header** (checked first): `Authorization: Bearer {token}`
+ - Takes precedence when present
+ - Used by mobile apps, API clients, and explicit web requests
+
+2. **HttpOnly Cookie** (fallback): Cookie named `ct_jwt` (configurable)
+ - Only checked if Authorization header is missing or empty
+ - Automatically sent by browser on subsequent requests
+ - HttpOnly flag prevents JavaScript access
+
+3. **Token Refresh**: When `X-Authorization-Reset: true` header is sent, a new JWT is generated and set in both the response header and cookie (if enabled)
+
+### Cookie Name, Flags, and Defaults
+
+- **Cookie Name**: `ct_jwt` (configurable via `config.jwt.cookie.name`)
+- **HttpOnly**: `true` (prevents JavaScript access, mitigates XSS)
+- **SameSite**: `:lax` (CSRF protection while allowing top-level navigation)
+- **Secure**: `false` in development, `true` in production (HTTPS only)
+- **Path**: `/` (configurable)
+- **Domain**: `nil` by default (host-only), can be set for subdomain sharing
+- **TTL**: Matches JWT TTL (default: 7 days, configurable)
+
+## When to Use Cookie vs Header Authentication
+
+### Use Cookie Authentication For:
+- **Web Applications**: Single-page applications (SPAs), traditional web apps, or any browser-based application
+- **Session Persistence**: When you want users to remain logged in after page refreshes without storing tokens in JavaScript
+- **Security**: When you want to prevent XSS attacks by keeping tokens inaccessible to JavaScript (HttpOnly cookies)
+- **Automatic Token Management**: When you want the browser to automatically send credentials on each request
+
+### Use Header Authentication For:
+- **Mobile Applications**: iOS, Android, or React Native apps that need explicit token management
+- **API Clients**: Command-line tools, scripts, or server-to-server communication
+- **Microservices**: Service-to-service authentication where cookies aren't appropriate
+- **Explicit Control**: When you need full control over when and how tokens are sent
+
+### Hybrid Approach (Recommended for Multi-Platform Apps)
+You can enable cookie authentication while still supporting header-based authentication. The system checks headers first, then falls back to cookies. This allows:
+- Web browsers to use cookies automatically
+- Mobile apps to use Authorization headers
+- API clients to use headers explicitly
+
+## Configuration
+
+### Step 1: Enable Cookie Authentication
+
+Add the following to your host app's initializer (`config/initializers/command_tower.rb`):
+
+```ruby
+CommandTower.configure do |config|
+ # Enable cookie-based authentication
+ config.jwt.cookie.enabled = true
+
+ # Optional: Customize cookie settings
+ # config.jwt.cookie.name = "ct_jwt" # Default: "ct_jwt"
+ # config.jwt.cookie.same_site = :lax # Default: :lax (:lax, :strict, or :none)
+ # config.jwt.cookie.secure = true # Default: false (auto-set to true in production)
+ # config.jwt.cookie.path = "/" # Default: "/"
+ # config.jwt.cookie.domain = nil # Default: nil (host-only)
+ # config.jwt.cookie.ttl = 7.days # Default: matches JWT TTL
+
+ # Optional: Enable double-submit CSRF protection for cookie-authenticated requests
+ # config.jwt.cookie.csrf.enabled = false # Default: false (disabled by default)
+ # config.jwt.cookie.csrf.cookie_name = "ct_csrf" # Default: "ct_csrf"
+ # config.jwt.cookie.csrf.header_name = "X-CSRF-Token" # Default: "X-CSRF-Token"
+ # config.jwt.cookie.csrf.rotate_on_login = true # Default: true
+ # config.jwt.cookie.csrf.rotate_on_reset = true # Default: true
+end
+```
+
+### Configuration Examples
+
+#### Basic Cookie Authentication
+
+```ruby
+CommandTower.configure do |config|
+ config.jwt.cookie.enabled = true
+end
+```
+
+#### Cookie Authentication with Custom Domain (for subdomain sharing)
+
+```ruby
+CommandTower.configure do |config|
+ config.jwt.cookie.enabled = true
+ config.jwt.cookie.domain = ".example.com" # Shared across subdomains
+end
+```
+
+#### Cookie Authentication with CSRF Protection
+
+```ruby
+CommandTower.configure do |config|
+ config.jwt.cookie.enabled = true
+ config.jwt.cookie.csrf.enabled = true
+ config.jwt.cookie.csrf.rotate_on_login = true # Rotate CSRF token on login
+ config.jwt.cookie.csrf.rotate_on_reset = true # Rotate CSRF token on token refresh
+end
+```
+
+### Step 2: Configure CORS for Cookie Support
+
+**Critical**: Cookie authentication requires proper CORS configuration. Without this, browsers will block cookie-based requests due to same-origin policy.
+
+#### For Rails Applications
+
+If your Rails app handles CORS, configure it in `config/initializers/cors.rb`:
+
+```ruby
+Rails.application.config.middleware.insert_before 0, Rack::Cors do
+ allow do
+ origins 'https://your-frontend-domain.com', # Production frontend
+ 'http://localhost:3000', # Development frontend
+ 'http://localhost:5173' # Vite dev server
+
+ resource '*',
+ headers: :any,
+ methods: [:get, :post, :patch, :put, :delete, :options, :head],
+ credentials: true # REQUIRED: Allows cookies to be sent cross-origin
+ end
+end
+```
+
+**⚠️ Important**: If you have an existing CORS configuration that uses `origins: "*"`, you **must** update it to explicitly list origins when enabling cookie authentication. Browsers will reject requests with `credentials: true` if `origins: "*"` is used.
+
+**Before (won't work with cookies)**:
+```ruby
+Rails.application.config.middleware.insert_before 0, Rack::Cors do
+ allow do
+ origins "*" # ❌ Cannot use wildcard with credentials: true
+
+ resource "*",
+ headers: :any,
+ methods: [:get, :post, :put, :patch, :delete, :options, :head]
+ # Missing credentials: true
+ end
+end
+```
+
+**After (works with cookies)**:
+```ruby
+Rails.application.config.middleware.insert_before 0, Rack::Cors do
+ allow do
+ origins 'https://your-frontend-domain.com', # ✅ Explicit origins
+ 'http://localhost:3000',
+ 'http://localhost:5173'
+
+ resource "*",
+ headers: :any,
+ methods: [:get, :post, :put, :patch, :delete, :options, :head],
+ credentials: true # ✅ Required for cookies
+ end
+end
+```
+
+**Key CORS Settings for Cookie Authentication**:
+- `credentials: true` - **REQUIRED** - Allows cookies to be sent in cross-origin requests
+- `origins` - Must explicitly list allowed origins (cannot use `*` when credentials are enabled)
+- `headers: :any` - Allows all headers (or specify `['Authorization', 'Content-Type', 'X-Authorization-Reset']`)
+
+#### For CommandTower Engine Only
+
+If CommandTower is the only Rails app and you need to configure CORS:
+
+```ruby
+# config/initializers/cors.rb
+Rails.application.config.middleware.insert_before 0, Rack::Cors do
+ allow do
+ origins CommandTower.config.app.app_url, # Use configured app URL
+ 'http://localhost:3000', # Development
+ 'http://localhost:5173' # Vite
+
+ resource '/command_tower/*', # Or your mounted path
+ headers: :any,
+ methods: [:get, :post, :patch, :put, :delete, :options, :head],
+ credentials: true
+ end
+end
+```
+
+#### Frontend Configuration
+
+Your frontend HTTP client must also be configured to send credentials:
+
+**Fetch API**:
+```javascript
+fetch('https://api.example.com/command_tower/user', {
+ credentials: 'include', // REQUIRED: Sends cookies with request
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+})
+```
+
+**Axios**:
+```javascript
+axios.defaults.withCredentials = true; // Global setting
+
+// Or per request
+axios.get('https://api.example.com/command_tower/user', {
+ withCredentials: true
+})
+```
+
+**XMLHttpRequest**:
+```javascript
+const xhr = new XMLHttpRequest();
+xhr.withCredentials = true; // REQUIRED
+xhr.open('GET', 'https://api.example.com/command_tower/user');
+xhr.send();
+```
+
+### Step 3: Client Requirements for CSRF Protection (If Enabled)
+
+If you've enabled CSRF protection (`config.jwt.cookie.csrf.enabled = true`), your frontend client must:
+
+1. **Read the CSRF cookie** from `document.cookie`
+2. **Send the CSRF token** in the `X-CSRF-Token` header (or configured header name) for unsafe HTTP methods (POST, PUT, PATCH, DELETE)
+3. **Handle CSRF errors** appropriately
+
+#### React SPA Example
+
+```javascript
+// Helper function to get CSRF token from cookie
+function getCsrfToken() {
+ const name = 'ct_csrf';
+ const cookies = document.cookie.split(';');
+ for (let cookie of cookies) {
+ const [key, value] = cookie.trim().split('=');
+ if (key === name) {
+ return decodeURIComponent(value);
+ }
+ }
+ return null;
+}
+
+// API client with CSRF support
+const apiClient = axios.create({
+ baseURL: process.env.REACT_APP_API_URL,
+ withCredentials: true,
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+});
+
+// Add CSRF token to unsafe requests
+apiClient.interceptors.request.use((config) => {
+ const method = config.method?.toUpperCase();
+ if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
+ const csrfToken = getCsrfToken();
+ if (csrfToken) {
+ config.headers['X-CSRF-Token'] = csrfToken;
+ }
+ }
+ return config;
+});
+
+// Handle CSRF errors
+apiClient.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ if (error.response?.status === 403) {
+ const message = error.response.data?.message;
+ if (message === 'csrf_missing' || message === 'csrf_mismatch') {
+ // Handle CSRF error - could redirect to login or show error
+ console.error('CSRF validation failed');
+ }
+ }
+ return Promise.reject(error);
+ }
+);
+```
+
+#### Fetch API Example
+
+```javascript
+// Helper function to get CSRF token
+function getCsrfToken() {
+ const name = 'ct_csrf';
+ const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
+ return match ? match[2] : null;
+}
+
+// Make authenticated request with CSRF token
+async function makeRequest(url, options = {}) {
+ const method = options.method?.toUpperCase();
+ const headers = {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ };
+
+ // Add CSRF token for unsafe methods
+ if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
+ const csrfToken = getCsrfToken();
+ if (csrfToken) {
+ headers['X-CSRF-Token'] = csrfToken;
+ }
+ }
+
+ const response = await fetch(url, {
+ ...options,
+ credentials: 'include', // REQUIRED for cookies
+ headers
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ if (response.status === 403 && (error.message === 'csrf_missing' || error.message === 'csrf_mismatch')) {
+ // Handle CSRF error
+ console.error('CSRF validation failed');
+ }
+ throw new Error(error.message || 'Request failed');
+ }
+
+ return response.json();
+}
+```
+
+#### Mobile / Service Clients
+
+**Mobile apps and API clients should use Authorization header authentication** instead of cookies:
+
+- Use `Authorization: Bearer {token}` header
+- Do NOT send cookies
+- CSRF protection does NOT apply to header-authenticated requests
+- No CSRF token handling needed
+
+## How It Works
+
+### Authentication Flow
+
+1. **Login Request**:
+ ```
+ POST /auth/login
+ Content-Type: application/json
+
+ {
+ "identifier": "user@example.com",
+ "password": "password123"
+ }
+ ```
+
+2. **Login Response** (when cookie auth enabled, CSRF enabled):
+ ```
+ HTTP/1.1 201 Created
+ X-Authorization-Reset: eyJhbGciOiJIUzI1NiJ9...
+ X-Authorization-Expire: "2025-01-27 01:42:10 +0000"
+ Set-Cookie: ct_jwt=eyJhbGciOiJIUzI1NiJ9...; path=/; expires=Tue, 27 Jan 2026 01:42:10 GMT; httponly; samesite=lax
+ Set-Cookie: ct_csrf=6c83347d417802fef533719c5fd41d0b210e9f9aa7a376e50eb0399c93109bd5; path=/; expires=Tue, 27 Jan 2026 01:42:10 GMT; samesite=lax
+ Content-Type: application/json
+
+ {
+ "token": "eyJhbGciOiJIUzI1NiJ9...",
+ "header_name": "Authorization",
+ "message": "Successfully logged user in",
+ "user": { ... }
+ }
+ ```
+
+ **Note**: When CSRF protection is enabled, both JWT and CSRF cookies are set on login. The CSRF cookie is NOT HttpOnly (readable by JavaScript).
+
+3. **Subsequent Requests**:
+ - Browser automatically includes the cookie in all requests to the same domain
+ - No need to manually set Authorization header (though it still works if provided)
+ - Token is extracted from cookie if header is missing
+
+### Token Extraction Priority
+
+The authentication system checks in this order:
+
+1. **Authorization Header** (checked first):
+ ```
+ Authorization: Bearer {token}
+ ```
+ - If present and valid, uses this token
+ - Takes precedence over cookie
+
+2. **HttpOnly Cookie** (fallback):
+ - Only checked if Authorization header is missing or empty
+ - Only used if cookie authentication is enabled
+ - Cookie name is configurable (default: `ct_jwt`)
+
+### Token Refresh
+
+When tokens are refreshed (via `X-Authorization-Reset: true` header):
+
+1. New token is generated
+2. Token is set in `X-Authorization-Reset` response header
+3. **Cookie is automatically updated** with the new token (if cookie auth enabled)
+4. Browser automatically uses the new cookie value on subsequent requests
+
+**Example Refresh Request**:
+```
+GET /user
+Authorization: Bearer {old_token}
+X-Authorization-Reset: true
+```
+
+**Response** (when CSRF is enabled and `rotate_on_reset = true`):
+```
+HTTP/1.1 200 OK
+X-Authorization-Reset: {new_token}
+X-Authorization-Expire: "2025-01-27 02:42:10 +0000"
+Set-Cookie: ct_jwt={new_token}; path=/; expires=Tue, 27 Jan 2026 02:42:10 GMT; httponly; samesite=lax
+Set-Cookie: ct_csrf={new_csrf_token}; path=/; expires=Tue, 27 Jan 2026 02:42:10 GMT; samesite=lax
+```
+
+**Note**: When CSRF protection is enabled and `rotate_on_reset = true`, the CSRF cookie is also rotated on token refresh. If `rotate_on_reset = false`, the CSRF cookie is only created if missing, but not rotated if it already exists.
+
+### Logout
+
+To clear the browser session:
+
+```
+POST /auth/logout
+Content-Type: application/json
+```
+
+**Response**:
+```
+HTTP/1.1 200 OK
+Set-Cookie: ct_jwt=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; httponly; samesite=lax
+Content-Type: application/json
+
+{
+ "message": "Logged out"
+}
+```
+
+**Important**: This is a browser-only logout. It does NOT reset the user's `verifier_token`, so tokens used by mobile apps or API clients remain valid. To log out of all sessions, use `POST /user/modify` with `verifier_token: true`.
+
+## Security Considerations
+
+### Cookie Security Features
+
+1. **HttpOnly**: Cookies are HttpOnly by default, preventing JavaScript access
+ - This prevents XSS attacks from exfiltrating tokens via JavaScript
+ - Tokens cannot be read from `document.cookie` or accessed via JavaScript APIs
+
+2. **SameSite**: Defaults to `Lax` to prevent CSRF attacks while allowing top-level navigation
+ - `:lax`: Cookie sent on top-level navigation, not on cross-site POST requests
+ - `:strict`: Cookie only sent on same-site requests (stricter CSRF protection)
+ - `:none`: Cookie sent on all requests (requires `Secure: true`)
+
+3. **Secure**: Automatically set to `true` in production (HTTPS only)
+ - Prevents man-in-the-middle attacks
+ - Cookie only transmitted over encrypted connections
+
+4. **Path**: Configurable (default: `/`)
+ - Restricts cookie to specific URL paths
+
+5. **Domain**: Configurable (default: `nil` for host-only cookies)
+ - Host-only cookies are more secure (not shared across subdomains)
+ - Can be set to subdomain (e.g., `".example.com"`) for cross-subdomain sharing
+
+### CSRF Protection (Double-Submit)
+
+Cookie authentication is vulnerable to CSRF (Cross-Site Request Forgery) attacks if not properly protected. CommandTower provides CSRF protection through:
+
+1. **SameSite Cookie Attribute**: The default `SameSite=Lax` setting provides basic CSRF protection by preventing cookies from being sent on cross-site POST requests.
+
+2. **Double-Submit CSRF Protection** (Optional): For additional CSRF protection, you can enable double-submit CSRF tokens. This requires the client to send a CSRF token in both a cookie (automatically sent by the browser) and a custom header (set by JavaScript).
+
+#### How Double-Submit CSRF Works
+
+When CSRF protection is enabled:
+- A CSRF token is set in a **non-HttpOnly cookie** (readable by JavaScript)
+- The client must read this cookie and send the same token in a custom header (default: `X-CSRF-Token`)
+- The server validates that the cookie token matches the header token
+- This protection only applies to **cookie-authenticated unsafe HTTP methods** (POST, PUT, PATCH, DELETE)
+- **Authorization header authentication is always exempt** from CSRF checks
+
+#### CSRF Enforcement Rules
+
+- **Enabled only when**: CSRF is enabled AND token source is `:cookie` AND HTTP method is unsafe (POST, PUT, PATCH, DELETE)
+- **Always exempt**: Authorization header authentication, GET/HEAD/OPTIONS requests
+- **Error responses**: Returns `403 Forbidden` with error code `csrf_missing` or `csrf_mismatch`
+
+#### CSRF Cookie Issuance
+
+CSRF cookies are automatically managed:
+
+- **On Login**: CSRF cookie is created/rotated based on `rotate_on_login` setting
+ - `rotate_on_login = true`: Always generates a new token
+ - `rotate_on_login = false`: Creates cookie if missing, keeps existing if present
+- **On Token Reset**: CSRF cookie is created/rotated based on `rotate_on_reset` setting
+ - `rotate_on_reset = true`: Always generates a new token
+ - `rotate_on_reset = false`: Creates cookie if missing, keeps existing if present
+- **On Logout**: CSRF cookie is always cleared (not configurable)
+
+### JWT Token Security
+
+1. **Token Refresh Does Not Revoke Old Tokens**: When a token is refreshed (via `X-Authorization-Reset: true`), a new JWT is generated but the old token remains valid until it expires. This is by design - JWTs are stateless and don't require server-side session storage.
+
+2. **Global Logout**: To log out of all sessions (including mobile apps and API clients), you must reset the user's `verifier_token`:
+ ```ruby
+ # Via API endpoint (if available)
+ POST /user/modify
+ { "verifier_token": true }
+ ```
+ This invalidates all existing JWTs for that user, as they all contain the old `verifier_token` value.
+
+3. **Browser-Only Logout**: The `/auth/logout` endpoint only clears the browser cookie. It does NOT reset `verifier_token`, so tokens used by mobile apps or API clients remain valid.
+
+### Recommended Settings by Environment
+
+**Development**:
+```ruby
+config.jwt.cookie.enabled = true
+config.jwt.cookie.secure = false # Allow HTTP
+config.jwt.cookie.same_site = :lax
+```
+
+**Production**:
+```ruby
+config.jwt.cookie.enabled = true
+config.jwt.cookie.secure = true # HTTPS only (auto-set)
+config.jwt.cookie.same_site = :lax # Or :strict for stricter CSRF protection
+# Optional: Enable CSRF protection for additional security
+config.jwt.cookie.csrf.enabled = true
+```
+
+**Production with CSRF Protection**:
+```ruby
+config.jwt.cookie.enabled = true
+config.jwt.cookie.secure = true
+config.jwt.cookie.same_site = :lax
+config.jwt.cookie.csrf.enabled = true
+config.jwt.cookie.csrf.rotate_on_login = true
+config.jwt.cookie.csrf.rotate_on_reset = true
+```
+
+**Cross-Domain (Multiple Subdomains)**:
+```ruby
+config.jwt.cookie.enabled = true
+config.jwt.cookie.domain = ".example.com" # Shared across subdomains
+config.jwt.cookie.same_site = :lax
+# Optional: Enable CSRF protection
+config.jwt.cookie.csrf.enabled = true
+```
+
+### CORS Security
+
+- **Never use `origins: '*'`** when `credentials: true` is set
+- Always explicitly list allowed origins
+- Use HTTPS in production
+- Consider using `same_site: :strict` for additional CSRF protection if your app doesn't need cross-site navigation
+
+## Common Patterns
+
+### Pattern 1: SPA with Separate API Domain
+
+**Setup**:
+- Frontend: `https://app.example.com`
+- Backend: `https://api.example.com`
+
+**CORS Configuration**:
+```ruby
+Rails.application.config.middleware.insert_before 0, Rack::Cors do
+ allow do
+ origins 'https://app.example.com'
+
+ resource '*',
+ headers: :any,
+ methods: [:get, :post, :patch, :put, :delete, :options, :head],
+ credentials: true
+ end
+end
+```
+
+**Cookie Configuration**:
+```ruby
+config.jwt.cookie.domain = ".example.com" # Shared across subdomains
+config.jwt.cookie.same_site = :lax
+```
+
+### Pattern 2: Same-Origin Application
+
+**Setup**:
+- Frontend and Backend: `https://example.com`
+
+**CORS Configuration**:
+```ruby
+# CORS not strictly necessary for same-origin, but can be configured
+Rails.application.config.middleware.insert_before 0, Rack::Cors do
+ allow do
+ origins 'https://example.com'
+
+ resource '*',
+ headers: :any,
+ methods: [:get, :post, :patch, :put, :delete, :options, :head],
+ credentials: true
+ end
+end
+```
+
+**Cookie Configuration**:
+```ruby
+config.jwt.cookie.domain = nil # Host-only (default)
+config.jwt.cookie.same_site = :lax
+```
+
+### Pattern 3: Development with Hot Reload
+
+**Setup**:
+- Frontend Dev Server: `http://localhost:5173` (Vite) or `http://localhost:3000` (Create React App)
+- Backend: `http://localhost:7777`
+
+**CORS Configuration**:
+```ruby
+Rails.application.config.middleware.insert_before 0, Rack::Cors do
+ allow do
+ origins 'http://localhost:5173',
+ 'http://localhost:3000',
+ 'http://localhost:8080' # Vue CLI default
+
+ resource '*',
+ headers: :any,
+ methods: [:get, :post, :patch, :put, :delete, :options, :head],
+ credentials: true
+ end
+end
+```
+
+**Cookie Configuration**:
+```ruby
+config.jwt.cookie.enabled = true
+config.jwt.cookie.secure = false # Allow HTTP in development
+config.jwt.cookie.same_site = :lax
+```
+
+## Troubleshooting
+
+### Cookies Not Being Set
+
+**Problem**: Login succeeds but cookie is not set in browser.
+
+**Solutions**:
+1. Check that `config.jwt.cookie.enabled = true` is set
+2. Verify CORS is configured with `credentials: true`
+3. Check browser console for CORS errors
+4. Ensure frontend is sending requests with `credentials: 'include'` (Fetch) or `withCredentials: true` (Axios)
+5. Verify cookie domain matches your frontend domain (or use subdomain wildcard)
+
+### Cookies Not Being Sent
+
+**Problem**: Cookie is set but not sent with subsequent requests.
+
+**Solutions**:
+1. Verify `credentials: 'include'` is set in all fetch requests
+2. Check that request URL matches cookie domain/path
+3. Ensure cookie hasn't expired
+4. Check browser DevTools → Application → Cookies to verify cookie exists
+5. Verify CORS allows credentials
+
+### CORS Errors
+
+**Problem**: Browser shows CORS errors when making requests.
+
+**Solutions**:
+1. Ensure `credentials: true` is set in CORS configuration
+2. Verify `origins` explicitly lists your frontend domain (cannot use `*`)
+3. Check that frontend is using `credentials: 'include'` in requests
+4. Verify `Access-Control-Allow-Credentials: true` header is present in response
+5. Check browser console for specific CORS error messages
+
+### Authentication Fails After Page Refresh
+
+**Problem**: User is logged in but gets 401 after refreshing page.
+
+**Solutions**:
+1. Verify cookie is being set (check browser DevTools)
+2. Ensure cookie hasn't expired (check cookie expiration)
+3. Verify cookie authentication is enabled
+4. Check that cookie name matches configuration
+5. Ensure CORS allows credentials
+
+### Token Refresh Doesn't Update Cookie
+
+**Problem**: Token is refreshed but cookie still has old value.
+
+**Solutions**:
+1. Verify cookie authentication is enabled
+2. Check that `X-Authorization-Reset: true` header is being sent
+3. Verify response includes `Set-Cookie` header with new token
+4. Check browser DevTools → Network tab to see cookie update
+
+### CSRF Errors
+
+**Problem**: Requests return `403 Forbidden` with `csrf_missing` or `csrf_mismatch` error.
+
+**Error: `csrf_missing`**
+- **Cause**: CSRF token is missing from either the cookie or the header
+- **Solutions**:
+ 1. Verify CSRF protection is enabled (`config.jwt.cookie.csrf.enabled = true`)
+ 2. Check that CSRF cookie exists in browser DevTools → Application → Cookies
+ 3. Verify client is reading CSRF cookie from `document.cookie`
+ 4. Ensure client is sending `X-CSRF-Token` header (or configured header name) for unsafe methods
+ 5. Check that CSRF cookie is not HttpOnly (it must be readable by JavaScript)
+
+**Error: `csrf_mismatch`**
+- **Cause**: CSRF token in cookie doesn't match the token in the header
+- **Solutions**:
+ 1. Verify client is reading the correct CSRF cookie value
+ 2. Ensure the same token value is sent in both cookie and header
+ 3. Check for token encoding/decoding issues
+ 4. Verify CSRF cookie hasn't been rotated (e.g., after login) without updating the header value
+
+**General CSRF Troubleshooting**:
+- CSRF protection only applies to cookie-authenticated unsafe methods (POST, PUT, PATCH, DELETE)
+- Authorization header authentication is always exempt from CSRF checks
+- GET/HEAD/OPTIONS requests never require CSRF tokens
+- Verify CSRF cookie is set after login (check `Set-Cookie` header in login response)
+
+## Best Practices
+
+1. **Always Use HTTPS in Production**: Cookies with `Secure` flag require HTTPS
+2. **Explicit Origins**: Never use `origins: '*'` with `credentials: true`
+3. **Monitor Cookie Expiration**: Set up client-side logic to refresh tokens before expiration
+4. **Handle Logout Properly**: Use `/auth/logout` for browser sessions, `verifier_token: true` for all sessions
+5. **Test Cross-Origin**: Test your CORS configuration in development with different origins
+6. **Use SameSite=Lax**: Provides good balance of security and functionality
+7. **Monitor Security Headers**: Ensure your application sets appropriate security headers
+8. **Enable CSRF Protection for Web Apps**: For production web applications, enable double-submit CSRF protection for additional security beyond SameSite cookies
+9. **Test CSRF Flow**: Verify CSRF token is read correctly and sent in headers for all unsafe methods
+10. **Handle CSRF Errors Gracefully**: Implement proper error handling for `csrf_missing` and `csrf_mismatch` errors
+
+## Migration from Header-Only to Cookie Support
+
+If you're migrating an existing application:
+
+1. **Enable Cookie Auth** (non-breaking):
+ ```ruby
+ config.jwt.cookie.enabled = true
+ ```
+ - Existing header-based clients continue to work
+ - Cookies are set as additional authentication method
+
+2. **Update Frontend**:
+ - Add `credentials: 'include'` to all fetch requests
+ - Remove manual token storage/retrieval from localStorage
+ - Update login flow to rely on automatic cookie handling
+
+3. **Configure CORS**:
+ - Add CORS middleware with `credentials: true`
+ - List allowed origins explicitly
+
+4. **Test Both Methods**:
+ - Verify header authentication still works (for mobile/API clients)
+ - Verify cookie authentication works (for web clients)
+
+## Example: Complete Setup
+
+### Backend Configuration
+
+```ruby
+# config/initializers/command_tower.rb
+CommandTower.configure do |config|
+ config.jwt.cookie.enabled = true
+ config.jwt.cookie.name = "ct_jwt"
+ config.jwt.cookie.same_site = :lax
+ config.jwt.cookie.secure = Rails.env.production?
+ config.jwt.cookie.path = "/"
+end
+
+# config/initializers/cors.rb
+Rails.application.config.middleware.insert_before 0, Rack::Cors do
+ allow do
+ origins Rails.env.production? ?
+ ['https://app.example.com'] :
+ ['http://localhost:3000', 'http://localhost:5173']
+
+ resource '*',
+ headers: :any,
+ methods: [:get, :post, :patch, :put, :delete, :options, :head],
+ credentials: true
+ end
+end
+```
+
+### Frontend Configuration (React Example)
+
+```javascript
+// api/client.js
+import axios from 'axios';
+
+const apiClient = axios.create({
+ baseURL: process.env.REACT_APP_API_URL,
+ withCredentials: true, // REQUIRED for cookies
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+});
+
+// Login
+export const login = async (identifier, password) => {
+ const response = await apiClient.post('/auth/login', {
+ identifier,
+ password
+ });
+ // Cookie is automatically set by browser
+ // Token is also in response.data.token if needed
+ return response.data;
+};
+
+// Logout
+export const logout = async () => {
+ await apiClient.post('/auth/logout');
+ // Cookie is automatically cleared by browser
+};
+
+// Authenticated request (cookie sent automatically)
+export const getCurrentUser = async () => {
+ const response = await apiClient.get('/user');
+ return response.data;
+};
+```
+
+## Integration Expectations
+
+### Host App CORS Configuration
+
+**Host applications must configure CORS** to allow credentials and expose refresh headers. This is a requirement for cookie authentication to work in cross-origin scenarios.
+
+**Required CORS Settings**:
+- `credentials: true` - **REQUIRED** - Allows cookies to be sent cross-origin
+- `origins` - Must explicitly list allowed origins (cannot use `*` when credentials are enabled)
+- `headers` - Must allow `Authorization`, `X-Authorization-Reset`, and `Content-Type` headers
+- `exposed_headers` - Should expose `X-Authorization-Reset` and `X-Authorization-Expire` headers
+
+**Note**: CommandTower does not configure CORS automatically. Host apps must configure CORS middleware (e.g., `Rack::Cors`) in their initializers. See the [Configuration](#step-2-configure-cors-for-cookie-support) section for examples.
+
+### SPA Token Storage
+
+**When cookie mode is enabled, SPAs should NOT store JWT tokens in localStorage or sessionStorage.**
+
+- Cookies are automatically managed by the browser
+- Storing tokens in localStorage defeats the security benefits of HttpOnly cookies
+- If you need token access in JavaScript (not recommended), use the `X-Authorization-Reset` response header, but be aware this reduces security
+
+**Best Practice**: Rely entirely on cookies for web applications. Only use header-based authentication for mobile apps and API clients.
+
+### Automatic Cookie Management
+
+The browser automatically:
+- Sends cookies with every request to the same domain
+- Updates cookies when `Set-Cookie` headers are received
+- Clears cookies when expiration is set in the past
+
+**No JavaScript code is needed** to manage cookies when using HttpOnly cookies.
+
+### Token Refresh Headers
+
+When token refresh is requested (via `X-Authorization-Reset: true` header), the response includes:
+- `X-Authorization-Reset`: New JWT token (for header-based clients)
+- `X-Authorization-Expire`: Token expiration timestamp
+- `Set-Cookie`: Updated cookie with new token (if cookie auth enabled)
+
+**Frontend clients should**:
+- Monitor the `X-Authorization-Expire` header to know when to refresh
+- Send `X-Authorization-Reset: true` header before token expiration
+- Update stored tokens (if using header auth) from `X-Authorization-Reset` header
+- Let the browser handle cookie updates automatically
+
+## Summary
+
+Cookie authentication provides a secure, convenient way to handle JWT tokens in web applications:
+
+- **Automatic**: Browser handles cookie sending/receiving
+- **Secure**: HttpOnly cookies prevent XSS attacks
+- **Persistent**: Survives page refreshes
+- **Flexible**: Works alongside header authentication
+
+Remember to:
+- Enable cookie authentication in configuration
+- Configure CORS with `credentials: true` (host app responsibility)
+- Set frontend to send credentials
+- Do NOT store JWT tokens in localStorage when cookie mode is enabled
+- If using CSRF protection, ensure client reads CSRF cookie and sends it in headers for unsafe methods
+- Test in both development and production environments
+
+## Enabling CSRF Protection
+
+### Step 1: Enable CSRF in Configuration
+
+Add the following to your host app's initializer (`config/initializers/command_tower.rb`):
+
+```ruby
+CommandTower.configure do |config|
+ # Enable cookie authentication (required for CSRF)
+ config.jwt.cookie.enabled = true
+
+ # Enable CSRF protection
+ config.jwt.cookie.csrf.enabled = true
+
+ # Optional: Configure CSRF behavior
+ # config.jwt.cookie.csrf.cookie_name = "ct_csrf" # Default: "ct_csrf"
+ # config.jwt.cookie.csrf.header_name = "X-CSRF-Token" # Default: "X-CSRF-Token"
+ # config.jwt.cookie.csrf.rotate_on_login = true # Default: true (always rotate on login)
+ # config.jwt.cookie.csrf.rotate_on_reset = true # Default: true (always rotate on token reset)
+end
+```
+
+### Step 2: Update Frontend Client
+
+Your frontend must:
+1. Read the CSRF cookie from `document.cookie` after login
+2. Send the CSRF token in the `X-CSRF-Token` header for all unsafe HTTP methods (POST, PUT, PATCH, DELETE)
+3. Handle CSRF errors appropriately
+
+See the [Client Requirements for CSRF Protection](#step-3-client-requirements-for-csrf-protection-if-enabled) section above for complete frontend implementation examples.
+
+### Step 3: Verify CSRF Cookie is Set
+
+After login, check that the CSRF cookie is present:
+- Browser DevTools → Application → Cookies → Look for `ct_csrf` cookie
+- The cookie should NOT have the HttpOnly flag (must be readable by JavaScript)
+- The cookie should have the same path, domain, and secure settings as the JWT cookie
+
+### Step 4: Test CSRF Protection
+
+1. **Test with missing CSRF header**: Make a POST request without the `X-CSRF-Token` header → Should return `403 Forbidden` with `csrf_missing`
+2. **Test with mismatched tokens**: Set different values in cookie and header → Should return `403 Forbidden` with `csrf_mismatch`
+3. **Test with matching tokens**: Set same value in cookie and header → Should succeed
+4. **Test header auth exemption**: Use `Authorization: Bearer {token}` header → Should succeed without CSRF token
+
+### CSRF Configuration Options
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `csrf.enabled` | `false` | Enable/disable CSRF protection |
+| `csrf.cookie_name` | `"ct_csrf"` | Name of the CSRF cookie (must NOT be HttpOnly) |
+| `csrf.header_name` | `"X-CSRF-Token"` | Name of the CSRF token header |
+| `csrf.rotate_on_login` | `true` | Always generate new CSRF token on login |
+| `csrf.rotate_on_reset` | `true` | Always generate new CSRF token on token refresh |
+| `csrf.same_site` | `nil` (inherits from JWT cookie) | SameSite attribute for CSRF cookie |
+| `csrf.secure` | `nil` (inherits from JWT cookie) | Secure flag for CSRF cookie |
+| `csrf.path` | `nil` (inherits from JWT cookie) | Path for CSRF cookie |
+| `csrf.domain` | `nil` (inherits from JWT cookie) | Domain for CSRF cookie |
+| `csrf.ttl` | `7.days` | Time to live for CSRF cookie |
+
+**Note**: When CSRF cookie attributes (`same_site`, `secure`, `path`, `domain`) are set to `nil`, they inherit from the JWT cookie configuration. This ensures consistent cookie behavior across your application.
diff --git a/docs/email_verification_workflow.md b/docs/email_verification_workflow.md
new file mode 100644
index 0000000..51ebba8
--- /dev/null
+++ b/docs/email_verification_workflow.md
@@ -0,0 +1,799 @@
+# Email Verification Workflow
+
+## Table of Contents
+
+1. [Overview](#overview)
+2. [Workflow Diagram](#workflow-diagram)
+3. [Key Concepts](#key-concepts)
+4. [Authentication Requirements](#authentication-requirements)
+5. [Verification Method](#verification-method)
+6. [Grace Period Behavior](#grace-period-behavior)
+7. [Backend Implementation](#backend-implementation)
+8. [Configuration Options](#configuration-options)
+9. [API Endpoints](#api-endpoints)
+10. [Error Handling](#error-handling)
+11. [Security Considerations](#security-considerations)
+12. [Complete User Journey](#complete-user-journey)
+
+---
+
+## Overview
+
+Email verification in CommandTower is a **code-based verification system** that ensures users have access to the email address they registered with. This system provides a grace period during which users can access the API before verification is required, allowing for a smooth onboarding experience while maintaining security.
+
+### Key Characteristics
+
+- **Verification Method**: 6-digit numeric code (configurable length)
+- **Code Delivery**: Sent via email to the user's registered email address
+- **Code Expiration**: 10 minutes by default (configurable)
+- **Authentication Required**: Users must be logged in (have a valid JWT token) to request or verify codes
+- **Grace Period**: Configurable time window before verification becomes mandatory
+- **RBAC Access**: During grace period, users have full API access based on their assigned roles
+
+---
+
+## Workflow Diagram
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ EMAIL VERIFICATION WORKFLOW │
+└─────────────────────────────────────────────────────────────────┘
+
+1. USER REGISTRATION
+ ┌──────────────┐
+ │ User Registers│
+ │ POST /auth/ │
+ │ create │
+ └──────┬───────┘
+ │
+ ▼
+ ┌──────────────────────┐
+ │ Account Created │
+ │ email_validated = │
+ │ false │
+ │ created_at = now │
+ └──────┬───────────────┘
+ │
+ ▼
+2. USER LOGIN
+ ┌──────────────┐
+ │ User Logs In │
+ │ POST /auth/ │
+ │ login │
+ └──────┬───────┘
+ │
+ ▼
+ ┌──────────────────────┐
+ │ Receives JWT Token │
+ │ Token contains: │
+ │ - user_id │
+ │ - verifier_token │
+ │ - generated_at │
+ └──────┬───────────────┘
+ │
+ ▼
+3. GRACE PERIOD ACTIVE
+ ┌──────────────────────┐
+ │ Grace Period Window │
+ │ (configurable) │
+ │ │
+ │ User can access API │
+ │ based on RBAC roles │
+ │ Email check bypassed │
+ └──────┬───────────────┘
+ │
+ ▼
+4. REQUEST VERIFICATION CODE
+ ┌──────────────────────┐
+ │ User Requests Code │
+ │ POST /auth/email/send│
+ │ [AUTH REQUIRED] │
+ └──────┬───────────────┘
+ │
+ ▼
+5. CODE GENERATION & STORAGE
+ ┌──────────────────────┐
+ │ System Generates │
+ │ 6-Digit Code │
+ │ │
+ │ Stored in: │
+ │ - user_secrets table │
+ │ - secret field │
+ │ - death_time set │
+ │ (expiration) │
+ └──────┬───────────────┘
+ │
+ ▼
+6. EMAIL DELIVERY
+ ┌──────────────────────┐
+ │ Email Sent via │
+ │ ActiveMailer │
+ │ │
+ │ Contains: │
+ │ - 6-digit code │
+ │ - Expiration time │
+ └──────┬───────────────┘
+ │
+ ▼
+7. USER SUBMITS CODE
+ ┌──────────────────────┐
+ │ User Enters Code │
+ │ POST /auth/email/ │
+ │ verify │
+ │ [AUTH REQUIRED] │
+ │ Body: { code: "..." }│
+ └──────┬───────────────┘
+ │
+ ▼
+8. CODE VALIDATION
+ ┌──────────────────────┐
+ │ System Validates: │
+ │ - Code exists │
+ │ - Code not expired │
+ │ - Code matches user │
+ │ - Code not used │
+ └──────┬───────────────┘
+ │
+ ┌─────┴─────┐
+ │ │
+ ▼ ▼
+┌─────────┐ ┌──────────────┐
+│ Valid │ │ Invalid/ │
+│ │ │ Expired │
+└────┬────┘ └──────┬───────┘
+ │ │
+ │ ▼
+ │ ┌──────────────┐
+ │ │ 403 Forbidden│
+ │ │ User can │
+ │ │ request new │
+ │ │ code │
+ │ └──────────────┘
+ │
+ ▼
+9. VERIFICATION SUCCESS
+ ┌──────────────────────┐
+ │ email_validated │
+ │ = true │
+ │ │
+ │ User can now access │
+ │ all endpoints │
+ │ (no grace period │
+ │ restrictions) │
+ └──────────────────────┘
+```
+
+---
+
+## Key Concepts
+
+### Email Validation Status
+
+Each user has an `email_validated` boolean field in the database that tracks whether their email has been verified. This field:
+- Defaults to `false` when a user is created
+- Is set to `true` after successful code verification
+- Is checked during authentication (if email verification is enabled)
+
+### Verification Code
+
+Verification codes are:
+- **Format**: Numeric codes (e.g., "123456")
+- **Length**: 6 digits by default (configurable)
+- **Storage**: Stored in the `user_secrets` table with:
+ - `secret`: The verification code
+ - `user_id`: Link to the user
+ - `death_time`: Expiration timestamp
+ - `reason`: Set to indicate this is an email verification code
+ - `use_count`: Tracks how many times the code has been used
+ - `use_count_max`: Maximum allowed uses (typically 1)
+
+### Grace Period
+
+The grace period is a configurable time window (measured from account creation) during which:
+- Users can access the API without email verification
+- Email validation checks are bypassed during authentication
+- RBAC authorization still applies normally
+- After expiration, email verification becomes mandatory
+
+**Default**: 0 minutes (immediate verification required)
+
+---
+
+## Authentication Requirements
+
+### Both Endpoints Require Authentication
+
+**Critical Point**: Users must be logged in (have a valid JWT token) to:
+1. Request a verification code (`POST /auth/email/send`)
+2. Verify a code (`POST /auth/email/verify`)
+
+### Why Authentication is Required
+
+This design ensures:
+- **Security**: Only the account owner can request verification codes
+- **User Identification**: The system knows which user's email to verify
+- **Prevention of Abuse**: Prevents unauthorized code requests for other users' emails
+- **Audit Trail**: All verification attempts are tied to authenticated users
+
+### Authentication Flow
+
+When a user makes a request to email verification endpoints:
+
+1. **Token Validation**: The JWT token is validated (decoded, checked for expiration, verifier token match)
+2. **User Identification**: The `user_id` from the token identifies the user
+3. **Email Validation Check**: If grace period has expired, the system checks if `email_validated` is true
+4. **Request Processing**: If authentication succeeds, the verification code request/verification proceeds
+
+---
+
+## Verification Method
+
+### Code-Based System (Not SSO Links)
+
+CommandTower uses a **numeric code verification system**, not single-sign-on (SSO) links. This approach:
+
+- **Works in any email client**: No need for special link handling
+- **Mobile-friendly**: Easy to copy/paste or manually enter
+- **Secure**: Codes expire quickly and are single-use
+- **Simple UX**: Users enter code in the app interface
+
+### Code Generation Process
+
+On the backend, when a verification code is requested:
+
+1. **Code Generation**: A random numeric code is generated (length configured via `verify_code_length`)
+2. **Expiration Calculation**: Expiration time is calculated as current time + `verify_code_link_valid_for` duration
+3. **Storage**: Code is stored in `user_secrets` table with:
+ - The generated code in the `secret` field
+ - Expiration time in `death_time` field
+ - User association via `user_id`
+ - Reason field set to indicate email verification
+4. **Email Composition**: Email is composed with the code and expiration information
+5. **Email Delivery**: Email is sent via ActiveMailer using configured SMTP settings
+
+### Code Validation Process
+
+When a user submits a code for verification:
+
+1. **Code Lookup**: System searches `user_secrets` table for:
+ - Matching `secret` (code value)
+ - Associated `user_id` matches the authenticated user
+ - `reason` indicates email verification
+2. **Expiration Check**: Verifies `death_time` has not passed
+3. **Usage Check**: Verifies `use_count` is less than `use_count_max`
+4. **Validation Success**: If all checks pass:
+ - `use_count` is incremented
+ - User's `email_validated` field is set to `true`
+ - Success response is returned
+5. **Validation Failure**: If any check fails, returns 403 Forbidden with error details
+
+---
+
+## Grace Period Behavior
+
+### How Grace Period Works
+
+The grace period is calculated from the user's account creation time (`created_at`). During this period:
+
+1. **Email Validation Bypass**: The email validation check in the authentication flow is skipped
+2. **Full RBAC Access**: Users have complete API access based on their assigned roles
+3. **Normal Authorization**: All RBAC authorization checks proceed normally
+4. **No Restrictions**: Users can access any endpoint their roles permit
+
+### Grace Period Expiration
+
+After the grace period expires:
+
+1. **Email Check Enforced**: The email validation check runs during authentication
+2. **Blocking Behavior**: If `email_validated` is `false`, authentication fails with 412 Precondition Failed
+3. **Error Message**: Returns "Email must be verified to continue"
+4. **Authorization Blocked**: Authorization checks never run because authentication fails first
+
+### Example Timeline
+
+**Configuration**: `verify_email_required_within = 24.hours`
+
+- **Day 1, 9:00 AM**: User registers → `created_at = 9:00 AM`, `email_validated = false`
+- **Day 1, 9:05 AM**: User logs in → receives JWT token
+- **Day 1, 9:10 AM**: User accesses admin endpoint → ✅ Success (within grace period, RBAC allows)
+- **Day 1, 2:00 PM**: User accesses user endpoint → ✅ Success (within grace period, RBAC allows)
+- **Day 2, 9:01 AM**: User accesses any endpoint → ❌ 412 Error (grace period expired, email not verified)
+- **Day 2, 9:05 AM**: User verifies email → `email_validated = true`
+- **Day 2, 9:10 AM**: User accesses admin endpoint → ✅ Success (email verified, RBAC allows)
+
+### RBAC Access During Grace Period
+
+**Important**: During the grace period, users have **full API access** based on their RBAC roles. This means:
+
+- Users with `admin` role can access admin endpoints
+- Users with `user` role can access user endpoints
+- All role-based permissions apply normally
+- The only difference is that email validation is not checked
+
+This design allows users to:
+- Complete onboarding flows
+- Access necessary features immediately after registration
+- Verify their email at their convenience (within the grace period)
+- Experience no interruption in service during the grace period
+
+---
+
+## Backend Implementation
+
+### Database Schema
+
+#### Users Table
+- `email`: String - User's email address
+- `email_validated`: Boolean - Verification status (default: false)
+- `created_at`: Timestamp - Used to calculate grace period expiration
+
+#### UserSecrets Table
+- `secret`: String - The verification code
+- `user_id`: Foreign Key - Links to the user
+- `death_time`: Timestamp - Code expiration time
+- `reason`: String - Identifies this as an email verification code
+- `use_count`: Integer - Number of times code has been used
+- `use_count_max`: Integer - Maximum allowed uses (typically 1)
+
+### Authentication Flow Integration
+
+Email validation is integrated into the JWT authentication flow as follows:
+
+1. **Token Decoding**: JWT token is decoded to extract `user_id`, `verifier_token`, and `generated_at`
+2. **User Lookup**: User is retrieved from database using `user_id`
+3. **Verifier Token Validation**: Token's `verifier_token` is compared with user's current `verifier_token`
+4. **Email Validation Check** (if email verification enabled):
+ - Calculate grace period expiration: `user.created_at + verify_email_required_within`
+ - If current time is within grace period: Skip email validation check
+ - If current time is after grace period: Check if `user.email_validated == true`
+ - If email not validated and grace period expired: Return 412 Precondition Failed
+5. **Authorization**: If authentication succeeds, RBAC authorization checks proceed
+
+### Code Generation Service
+
+The backend service that generates verification codes:
+
+1. **Validates User**: Ensures user exists and is authenticated
+2. **Checks Current Status**: If email already verified, returns success message
+3. **Generates Code**: Creates random numeric code of configured length
+4. **Calculates Expiration**: Sets expiration based on `verify_code_link_valid_for` configuration
+5. **Creates UserSecret Record**: Stores code in database with expiration
+6. **Sends Email**: Composes and sends email via ActiveMailer with:
+ - The verification code
+ - Expiration time information
+ - User-friendly instructions
+7. **Returns Response**: Confirms code has been sent
+
+### Code Verification Service
+
+The backend service that validates submitted codes:
+
+1. **Validates User**: Ensures user exists and is authenticated
+2. **Checks Current Status**: If email already verified, returns success message
+3. **Looks Up Code**: Searches `user_secrets` for:
+ - Matching code value
+ - Associated with the authenticated user
+ - Not expired (death_time > current time)
+ - Not already used (use_count < use_count_max)
+4. **Validates Code**: If code found and valid:
+ - Increments `use_count`
+ - Updates user's `email_validated` to `true`
+ - Returns success response
+5. **Handles Invalid Code**: If code not found, expired, or already used:
+ - Returns 403 Forbidden
+ - Provides appropriate error message
+
+### Email Delivery
+
+Email delivery is handled by Rails' ActiveMailer:
+
+1. **SMTP Configuration**: Uses configured SMTP settings (Gmail, custom SMTP, etc.)
+2. **Email Template**: Uses email template with:
+ - Verification code prominently displayed
+ - Expiration time
+ - Instructions for entering code
+ - Support contact information (if configured)
+3. **Delivery**: Sends email to user's registered email address
+4. **Error Handling**: Logs delivery errors and handles failures gracefully
+
+---
+
+## Configuration Options
+
+### Email Verification Configuration
+
+Email verification is configured in the CommandTower initializer:
+
+#### Enable/Disable Email Verification
+- **Setting**: `email_verify_config.enable`
+- **Type**: Boolean
+- **Default**: `false`
+- **Description**: Master switch for email verification feature
+
+#### Grace Period Duration
+- **Setting**: `email_verify_config.verify_email_required_within`
+- **Type**: ActiveSupport::Duration
+- **Default**: `0.minutes` (immediate verification required)
+- **Description**: Time window after account creation during which email verification is not required
+- **Examples**:
+ - `0.minutes` - Immediate verification required
+ - `24.hours` - 24-hour grace period
+ - `7.days` - 7-day grace period
+
+#### Code Validity Duration
+- **Setting**: `email_verify_config.verify_code_link_valid_for`
+- **Type**: ActiveSupport::Duration
+- **Default**: `10.minutes`
+- **Description**: How long verification codes remain valid after generation
+- **Examples**:
+ - `10.minutes` - Codes expire after 10 minutes
+ - `30.minutes` - Codes expire after 30 minutes
+ - `1.hour` - Codes expire after 1 hour
+
+#### Code Length
+- **Setting**: `email_verify_config.verify_code_length`
+- **Type**: Integer
+- **Default**: `6`
+- **Description**: Number of digits in verification codes
+- **Examples**:
+ - `4` - 4-digit codes (e.g., "1234")
+ - `6` - 6-digit codes (e.g., "123456")
+ - `8` - 8-digit codes (e.g., "12345678")
+
+### Email Configuration
+
+Email delivery is configured separately:
+
+- **SMTP Address**: `config.email.address` (default: "smtp.gmail.com")
+- **SMTP Port**: `config.email.port` (default: 587)
+- **Username**: `config.email.user_name` (default: `ENV['GMAIL_USER_NAME']`)
+- **Password**: `config.email.password` (default: `ENV['GMAIL_PASSWORD']`)
+- **Authentication**: `config.email.authentication` (default: "plain")
+- **TLS**: `config.email.enable_starttls_auto` (default: true)
+
+---
+
+## API Endpoints
+
+### 1. Send Verification Code
+
+**Endpoint**: `POST /auth/email/send`
+
+**Authentication**: Required (Bearer token)
+
+**Request Headers**:
+```
+Authorization: Bearer {jwt_token}
+Content-Type: application/json
+```
+
+**Request Body**: None
+
+**Response** (201 Created):
+```json
+{
+ "message": "Successfully sent Email verification code"
+}
+```
+
+**Response** (200 OK - if already verified):
+```json
+{
+ "message": "Email is already verified. No code required"
+}
+```
+
+**Error Responses**:
+- `401 Unauthorized`: Missing or invalid token
+- `412 Precondition Failed`: Grace period expired with unverified email
+
+**Backend Behavior**:
+1. Validates JWT token and identifies user
+2. Checks if email is already verified (returns 200 if yes)
+3. Generates new verification code
+4. Stores code in `user_secrets` with expiration
+5. Sends email with code
+6. Returns success response
+
+### 2. Verify Code
+
+**Endpoint**: `POST /auth/email/verify`
+
+**Authentication**: Required (Bearer token)
+
+**Request Headers**:
+```
+Authorization: Bearer {jwt_token}
+Content-Type: application/json
+```
+
+**Request Body**:
+```json
+{
+ "code": "123456"
+}
+```
+
+**Response** (201 Created):
+```json
+{
+ "message": "Successfully verified email"
+}
+```
+
+**Response** (200 OK - if already verified):
+```json
+{
+ "message": "Email is already verified."
+}
+```
+
+**Error Responses**:
+- `401 Unauthorized`: Missing or invalid token
+- `412 Precondition Failed`: Grace period expired with unverified email
+- `403 Forbidden`: Invalid, expired, or already-used verification code
+- `400 Bad Request`: Missing or invalid request body
+
+**Backend Behavior**:
+1. Validates JWT token and identifies user
+2. Checks if email is already verified (returns 200 if yes)
+3. Looks up code in `user_secrets` for the authenticated user
+4. Validates code is not expired and not already used
+5. If valid: increments use_count, sets `email_validated = true`
+6. Returns success response
+
+---
+
+## Error Handling
+
+### Authentication Errors (401)
+
+**Scenario**: User tries to request/verify code but authentication fails
+
+**Possible Causes**:
+- Missing Authorization header
+- Invalid or expired JWT token
+
+**Response**:
+```json
+{
+ "status": "401",
+ "message": "Unauthorized Access. Invalid Authorization token"
+}
+```
+
+### Email Validation Errors (412)
+
+**Scenario**: User's email is not validated and grace period has expired
+
+**Possible Causes**:
+- Grace period expired and email not verified
+
+**Response**:
+```json
+{
+ "status": "412",
+ "message": "Email must be verified to continue",
+ "meta": {
+ "email_validated": false
+ }
+}
+```
+
+### Invalid Code Errors (403)
+
+**Scenario**: User submits invalid, expired, or already-used code
+
+**Possible Causes**:
+- Code doesn't exist
+- Code has expired (past `death_time`)
+- Code has already been used (use_count >= use_count_max)
+- Code belongs to different user
+
+**Response**:
+```json
+{
+ "status": "403",
+ "message": "Invalid verification code"
+}
+```
+
+### Already Verified (200)
+
+**Scenario**: User requests code or verifies code but email is already verified
+
+**Response**:
+```json
+{
+ "message": "Email is already verified. No code required"
+}
+```
+or
+```json
+{
+ "message": "Email is already verified."
+}
+```
+
+### Resend Capability
+
+Users can request a new verification code if:
+- Previous code expired
+- Previous code was lost
+- User needs a fresh code
+
+Simply call `POST /auth/email/send` again. The system will:
+- Invalidate or ignore the previous code (if not used)
+- Generate a new code
+- Send a new email
+
+---
+
+## Security Considerations
+
+### Code Security
+
+1. **Single-Use Codes**: Codes are designed to be used once (use_count_max = 1)
+2. **Short Expiration**: Codes expire quickly (default 10 minutes) to limit exposure window
+3. **User Association**: Codes are tied to specific users and cannot be used by others
+4. **Secure Storage**: Codes are stored in database, not in URLs or client-side storage
+
+### Authentication Requirements
+
+1. **Token Required**: Both endpoints require valid JWT tokens
+2. **User Verification**: System verifies the authenticated user matches the code's user
+3. **Prevents Cross-User Attacks**: Users cannot verify codes for other users' emails
+
+### Rate Limiting Considerations
+
+While not explicitly implemented in the base system, consider:
+- Limiting code request frequency per user
+- Implementing cooldown periods between code requests
+- Monitoring for abuse patterns
+
+### Email Security
+
+1. **SMTP Security**: Use TLS/SSL for email transmission
+2. **Email Content**: Avoid including sensitive information beyond the code
+3. **Expiration Communication**: Clearly communicate code expiration to users
+
+### Grace Period Security
+
+1. **Time-Based**: Grace period is calculated from account creation, not login time
+2. **Consistent Enforcement**: Grace period expiration is checked on every authenticated request
+3. **No Bypass**: Once grace period expires, verification is mandatory
+
+---
+
+## Complete User Journey
+
+### Scenario 1: Immediate Verification Required (Grace Period = 0)
+
+1. **Registration** (9:00 AM)
+ - User creates account via `POST /auth/create`
+ - Account created with `email_validated = false`
+ - `created_at = 9:00 AM`
+
+2. **Login** (9:01 AM)
+ - User logs in via `POST /auth/login`
+ - Receives JWT token
+ - Token includes user information
+
+3. **API Access Attempt** (9:02 AM)
+ - User tries to access `GET /user/`
+ - Authentication checks email validation
+ - Grace period = 0, so check runs immediately
+ - `email_validated = false` → 412 Precondition Failed
+ - User must verify email before accessing API
+
+4. **Request Verification Code** (9:03 AM)
+ - User calls `POST /auth/email/send`
+ - System generates 6-digit code
+ - Code stored with 10-minute expiration
+ - Email sent with code
+
+5. **Receive Email** (9:04 AM)
+ - User receives email with code "123456"
+ - Email shows code expires at 9:14 AM
+
+6. **Verify Code** (9:05 AM)
+ - User calls `POST /auth/email/verify` with `{ "code": "123456" }`
+ - System validates code
+ - Sets `email_validated = true`
+ - Returns success
+
+7. **API Access** (9:06 AM)
+ - User accesses `GET /user/`
+ - Authentication succeeds (email validated)
+ - Authorization checks RBAC roles
+ - Request succeeds
+
+### Scenario 2: 24-Hour Grace Period
+
+1. **Registration** (Day 1, 9:00 AM)
+ - User creates account
+ - `created_at = Day 1, 9:00 AM`
+ - `email_validated = false`
+
+2. **Login** (Day 1, 9:05 AM)
+ - User logs in
+ - Receives JWT token
+
+3. **Immediate API Access** (Day 1, 9:10 AM)
+ - User accesses `GET /admin/` (has admin role)
+ - Authentication: Grace period active (24 hours from 9:00 AM)
+ - Email validation check bypassed
+ - Authorization: RBAC allows admin access
+ - ✅ Request succeeds
+
+4. **Continue Using API** (Day 1, throughout the day)
+ - User accesses various endpoints
+ - All requests succeed (within grace period)
+ - Email validation not checked
+
+5. **Request Verification Code** (Day 1, 2:00 PM)
+ - User calls `POST /auth/email/send`
+ - Receives code via email
+ - Can verify immediately or later
+
+6. **Verify Code** (Day 1, 2:05 PM)
+ - User verifies code
+ - `email_validated = true`
+ - No change in API access (still within grace period)
+
+7. **After Grace Period** (Day 2, 9:01 AM)
+ - Grace period expired (24 hours from Day 1, 9:00 AM)
+ - User accesses any endpoint
+ - Authentication: Email validation check runs
+ - `email_validated = true` → ✅ Check passes
+ - Authorization: RBAC checks proceed
+ - ✅ Request succeeds
+
+### Scenario 3: Grace Period Expires Without Verification
+
+1. **Registration** (Day 1, 9:00 AM)
+ - User creates account
+ - `created_at = Day 1, 9:00 AM`
+
+2. **Login & Usage** (Day 1, 9:05 AM - Day 2, 8:59 AM)
+ - User logs in and uses API successfully
+ - All requests succeed (within grace period)
+ - User never verifies email
+
+3. **Grace Period Expires** (Day 2, 9:01 AM)
+ - 24 hours have passed since account creation
+ - Grace period expired
+
+4. **API Access Blocked** (Day 2, 9:02 AM)
+ - User tries to access `GET /user/`
+ - Authentication: Email validation check runs
+ - `email_validated = false` → ❌ 412 Precondition Failed
+ - Error: "Email must be verified to continue"
+ - Authorization never runs (authentication failed)
+
+5. **User Verifies Email** (Day 2, 9:05 AM)
+ - User requests verification code
+ - Receives code via email
+ - Verifies code
+ - `email_validated = true`
+
+6. **API Access Restored** (Day 2, 9:06 AM)
+ - User accesses `GET /user/`
+ - Authentication: Email validation check passes
+ - Authorization: RBAC checks proceed
+ - ✅ Request succeeds
+
+---
+
+## Summary
+
+Email verification in CommandTower provides a flexible, secure system for ensuring users have access to their registered email addresses. Key takeaways:
+
+1. **Code-Based System**: Uses numeric codes, not SSO links
+2. **Authentication Required**: Users must be logged in to request/verify codes
+3. **Grace Period**: Configurable time window before verification becomes mandatory
+4. **Full RBAC Access**: During grace period, users have complete API access based on roles
+5. **Secure Implementation**: Codes expire quickly, are single-use, and tied to specific users
+6. **Flexible Configuration**: Grace period duration, code length, and expiration are all configurable
+
+This system balances security requirements with user experience, allowing immediate API access during onboarding while ensuring email verification is completed within a reasonable timeframe.
diff --git a/docs/messaging_integration_guide.md b/docs/messaging_integration_guide.md
new file mode 100644
index 0000000..920949a
--- /dev/null
+++ b/docs/messaging_integration_guide.md
@@ -0,0 +1,1343 @@
+# Command Tower Messaging Integration Guide
+
+This guide provides comprehensive information for integrating Single Page Applications (SPAs) and mobile applications with Command Tower's messaging system.
+
+## Table of Contents
+
+1. [Overview](#overview)
+2. [Authentication & Authorization](#authentication--authorization)
+3. [Messages vs Message Blasts](#messages-vs-message-blasts)
+4. [Message Endpoints](#message-endpoints)
+5. [Message Blast Endpoints](#message-blast-endpoints)
+6. [Pagination](#pagination)
+7. [Error Handling](#error-handling)
+8. [CSRF Protection](#csrf-protection)
+9. [Token Management](#token-management)
+
+---
+
+## Overview
+
+Command Tower provides a messaging system that allows:
+- **Users** to receive and manage individual messages in their inbox
+- **Administrators** to create and manage message blasts that are delivered to multiple users
+
+All messaging endpoints require authentication via JWT tokens. Some endpoints (message blasts) also require authorization (admin role).
+
+---
+
+## Authentication & Authorization
+
+### JWT Token Authentication
+
+All messaging endpoints require a valid JWT token. The token must be included in the request headers.
+
+#### Header-Based Authentication (Recommended for SPAs)
+
+Include the JWT token in the `Authorization` header:
+
+```
+Authorization: Bearer
+```
+
+**Example:**
+```javascript
+fetch('https://api.example.com/inbox/messages', {
+ headers: {
+ 'Authorization': `Bearer ${jwtToken}`,
+ 'Content-Type': 'application/json'
+ }
+})
+```
+
+#### Cookie-Based Authentication (Alternative)
+
+If cookie-based authentication is enabled in Command Tower, the JWT token can be stored in an HttpOnly cookie. The system will automatically read the token from the cookie if the `Authorization` header is not present.
+
+**Important:** When using cookie-based authentication, CSRF protection is required for unsafe HTTP methods (POST, PATCH, DELETE). See [CSRF Protection](#csrf-protection) section.
+
+### Authorization
+
+- **Messages**: Any authenticated user can access their own messages
+- **Message Blasts**: Requires admin role authorization. Unauthorized requests return `403 Forbidden`
+
+### Authentication Errors
+
+- **401 Unauthorized**: Invalid or missing JWT token
+- **403 Forbidden**: Valid token but insufficient permissions (for message blast endpoints)
+- **412 Precondition Failed**: Valid token but email verification required
+
+---
+
+## Messages vs Message Blasts
+
+### Messages
+
+**Messages** are individual user-specific notifications in a user's inbox. Each message:
+- Belongs to a specific user
+- Can be associated with a message blast (optional)
+- Has properties: `id`, `title`, `text`, `viewed`, `created_at`
+- Can be marked as viewed (acknowledged) or deleted
+- Automatically marked as `viewed: true` when retrieved individually
+
+**Key Characteristics:**
+- User-scoped: Users can only see and manage their own messages
+- Individual operations: Each message is a separate record
+- View tracking: Messages have a `viewed` boolean flag
+
+### Message Blasts
+
+**Message Blasts** are templates for broadcasting messages to multiple users. They:
+- Are created by administrators
+- Define target audiences (`existing_users`, `new_users` flags)
+- Generate individual `Message` records for each target user when created
+- Can be modified or deleted (which affects all associated messages)
+- Have properties: `id`, `title`, `text`, `existing_users`, `new_users`, `created_at`, `created_by`
+
+**Key Characteristics:**
+- Admin-only: Only users with admin role can create/manage blasts
+- Broadcast mechanism: Creating a blast generates messages for target users
+- Template-like: Blasts define the content that gets delivered to users
+
+**Relationship:**
+```
+MessageBlast (1) ──→ (many) Messages
+```
+
+When a message blast is created with `existing_users: true` and/or `new_users: true`, the system automatically creates individual `Message` records for all matching users.
+
+---
+
+## Message Endpoints
+
+### GET /inbox/messages
+
+Retrieves a paginated list of messages for the authenticated user.
+
+**Authentication:** Required (JWT token)
+
+**Authorization:** User can only see their own messages
+
+**Query Parameters (Pagination):**
+- `pagination=true` (required to enable query-based pagination)
+- `page=` (optional) - Page number (1-indexed)
+- `limit=` (optional) - Items per page (default: 10)
+- `cursor=` (optional) - Cursor-based pagination (takes precedence over `page`)
+
+**Request Body (Alternative Pagination):**
+```json
+{
+ "pagination": {
+ "page": 2,
+ "limit": 20,
+ "cursor": 15
+ }
+}
+```
+
+**Response (200 OK):**
+```json
+{
+ "count": 10,
+ "entities": [
+ {
+ "id": 1,
+ "title": "Welcome Message",
+ "viewed": false
+ },
+ {
+ "id": 2,
+ "title": "System Update",
+ "viewed": true
+ }
+ ],
+ "pagination": {
+ "current": {
+ "cursor": 0,
+ "limit": 10,
+ "query": "?pagination=true&page=1&limit=10"
+ },
+ "next": {
+ "cursor": 10,
+ "limit": 10,
+ "query": "?pagination=true&page=2&limit=10"
+ },
+ "current_page": 1,
+ "remaining_pages": 2,
+ "total_pages": 3,
+ "count_available": 25
+ }
+}
+```
+
+**Response Fields:**
+- `count`: Number of messages in the current page
+- `entities`: Array of message objects (without `text` field for list view)
+- `pagination`: Pagination metadata (see [Pagination](#pagination) section)
+
+**Example Request:**
+```javascript
+// Using query parameters
+const response = await fetch('https://api.example.com/inbox/messages?pagination=true&page=1&limit=20', {
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json'
+ }
+});
+
+// Using request body
+const response = await fetch('https://api.example.com/inbox/messages', {
+ method: 'GET',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ pagination: {
+ page: 1,
+ limit: 20
+ }
+ })
+});
+```
+
+---
+
+### GET /inbox/messages/:id
+
+Retrieves a single message by ID. **Automatically marks the message as viewed** when retrieved.
+
+**Authentication:** Required (JWT token)
+
+**Authorization:** User can only retrieve their own messages
+
+**URL Parameters:**
+- `id` (integer) - Message ID
+
+**Response (200 OK):**
+```json
+{
+ "id": 1,
+ "title": "Welcome Message",
+ "text": "Welcome to our platform! We're excited to have you.",
+ "viewed": true
+}
+```
+
+**Response Fields:**
+- `id`: Message ID
+- `title`: Message title
+- `text`: Full message content (only available in individual message view)
+- `viewed`: Boolean indicating if message has been viewed (always `true` after retrieval)
+
+**Error Responses:**
+- `400 Bad Request`: Message ID not found for user
+ ```json
+ {
+ "status": "400",
+ "message": "Message ID not found for user",
+ "invalid_argument_hash": {
+ "id": "Message ID not found for user"
+ }
+ }
+ ```
+
+**Example Request:**
+```javascript
+const response = await fetch(`https://api.example.com/inbox/messages/${messageId}`, {
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json'
+ }
+});
+
+const message = await response.json();
+```
+
+---
+
+### POST /inbox/messages/ack
+
+Marks one or more messages as viewed (acknowledged).
+
+**Authentication:** Required (JWT token)
+
+**Authorization:** User can only acknowledge their own messages
+
+**CSRF Protection:** Required if using cookie-based authentication
+
+**Request Body:**
+```json
+{
+ "ids": [1, 2, 3, 4, 5]
+}
+```
+
+**Response (200 OK):**
+```json
+{
+ "type": "viewed",
+ "ids": [1, 2, 3, 4, 5],
+ "count": 5
+}
+```
+
+**Response Fields:**
+- `type`: Always `"viewed"` for ack endpoint
+- `ids`: Array of message IDs that were successfully marked as viewed
+- `count`: Number of messages that were modified
+
+**Behavior:**
+- Only processes message IDs that belong to the authenticated user
+- Unknown IDs or IDs belonging to other users are silently ignored
+- Returns success even if some IDs are invalid (only valid IDs are processed)
+
+**Error Responses:**
+- `400 Bad Request`: No valid IDs found
+ ```json
+ {
+ "status": "400",
+ "message": "No ID's found for user",
+ "invalid_argument_hash": {
+ "ids": "No ID's found for user"
+ }
+ }
+ ```
+
+**Example Request:**
+```javascript
+const response = await fetch('https://api.example.com/inbox/messages/ack', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': csrfToken // Required if using cookie auth
+ },
+ body: JSON.stringify({
+ ids: [1, 2, 3, 4, 5]
+ })
+});
+```
+
+---
+
+### POST /inbox/messages/delete
+
+Deletes one or more messages.
+
+**Authentication:** Required (JWT token)
+
+**Authorization:** User can only delete their own messages
+
+**CSRF Protection:** Required if using cookie-based authentication
+
+**Request Body:**
+```json
+{
+ "ids": [1, 2, 3]
+}
+```
+
+**Response (200 OK):**
+```json
+{
+ "type": "delete",
+ "ids": [1, 2, 3],
+ "count": 3
+}
+```
+
+**Response Fields:**
+- `type`: Always `"delete"` for delete endpoint
+- `ids`: Array of message IDs that were successfully deleted
+- `count`: Number of messages that were deleted
+
+**Behavior:**
+- Only processes message IDs that belong to the authenticated user
+- Unknown IDs or IDs belonging to other users are silently ignored
+- Returns success even if some IDs are invalid (only valid IDs are processed)
+
+**Error Responses:**
+- `400 Bad Request`: No valid IDs found
+ ```json
+ {
+ "status": "400",
+ "message": "No ID's found for user",
+ "invalid_argument_hash": {
+ "ids": "No ID's found for user"
+ }
+ }
+ ```
+
+**Example Request:**
+```javascript
+const response = await fetch('https://api.example.com/inbox/messages/delete', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': csrfToken // Required if using cookie auth
+ },
+ body: JSON.stringify({
+ ids: [1, 2, 3]
+ })
+});
+```
+
+---
+
+## Message Blast Endpoints
+
+All message blast endpoints require **admin role authorization** in addition to authentication.
+
+### GET /inbox/blast
+
+Retrieves metadata about all message blasts (list view with pagination).
+
+**Authentication:** Required (JWT token)
+
+**Authorization:** Admin role required
+
+**Query Parameters (Pagination):**
+- `pagination=true` (required to enable query-based pagination)
+- `page=` (optional)
+- `limit=` (optional)
+- `cursor=` (optional)
+
+**Response (200 OK):**
+```json
+{
+ "count": 5,
+ "entities": [
+ {
+ "id": 1,
+ "title": "System Maintenance Notice",
+ "existing_users": true,
+ "new_users": false,
+ "created_by": {
+ "id": 1,
+ "username": "admin"
+ }
+ }
+ ],
+ "pagination": {
+ "current": {
+ "cursor": 0,
+ "limit": 10,
+ "query": "?pagination=true&page=1&limit=10"
+ },
+ "next": {
+ "cursor": 10,
+ "limit": 10,
+ "query": "?pagination=true&page=2&limit=10"
+ },
+ "current_page": 1,
+ "remaining_pages": 0,
+ "total_pages": 1,
+ "count_available": 5
+ }
+}
+```
+
+**Response Fields:**
+- `count`: Number of blasts in current page
+- `entities`: Array of message blast objects (may not include full `text` for performance)
+- `pagination`: Pagination metadata
+
+**Error Responses:**
+- `401 Unauthorized`: Missing or invalid JWT token
+- `403 Forbidden`: Valid token but user lacks admin role
+
+**Example Request:**
+```javascript
+const response = await fetch('https://api.example.com/inbox/blast?pagination=true&page=1', {
+ headers: {
+ 'Authorization': `Bearer ${adminToken}`,
+ 'Content-Type': 'application/json'
+ }
+});
+```
+
+---
+
+### GET /inbox/blast/:id
+
+Retrieves a single message blast by ID.
+
+**Authentication:** Required (JWT token)
+
+**Authorization:** Admin role required
+
+**URL Parameters:**
+- `id` (integer) - Message Blast ID
+
+**Response (200 OK):**
+```json
+{
+ "id": 1,
+ "title": "System Maintenance Notice",
+ "text": "We will be performing system maintenance on...",
+ "existing_users": true,
+ "new_users": false,
+ "created_by": {
+ "id": 1,
+ "username": "admin"
+ }
+}
+```
+
+**Response Fields:**
+- `id`: Message Blast ID
+- `title`: Blast title
+- `text`: Full blast content
+- `existing_users`: Boolean - whether to send to existing users
+- `new_users`: Boolean - whether to send to new users
+- `created_by`: User object who created the blast (optional field)
+
+**Error Responses:**
+- `400 Bad Request`: Message Blast ID not found
+ ```json
+ {
+ "status": "400",
+ "message": "MessageBlast ID not found",
+ "invalid_argument_hash": {
+ "id": "MessageBlast ID not found"
+ }
+ }
+ ```
+- `403 Forbidden`: User lacks admin role
+
+**Example Request:**
+```javascript
+const response = await fetch(`https://api.example.com/inbox/blast/${blastId}`, {
+ headers: {
+ 'Authorization': `Bearer ${adminToken}`,
+ 'Content-Type': 'application/json'
+ }
+});
+```
+
+---
+
+### POST /inbox/blast
+
+Creates a new message blast. This will automatically generate individual messages for all target users.
+
+**Authentication:** Required (JWT token)
+
+**Authorization:** Admin role required
+
+**CSRF Protection:** Required if using cookie-based authentication
+
+**Request Body:**
+```json
+{
+ "title": "Welcome New Users",
+ "text": "Welcome to our platform! We're excited to have you join us.",
+ "existing_users": false,
+ "new_users": true
+}
+```
+
+**Request Fields:**
+- `title` (string, required) - Blast title
+- `text` (string, optional) - Blast content
+- `existing_users` (boolean, optional) - Send to existing users (default: `false`)
+- `new_users` (boolean, optional) - Send to new users (default: `false`)
+
+**Response (200 OK):**
+```json
+{
+ "id": 5,
+ "title": "Welcome New Users",
+ "text": "Welcome to our platform! We're excited to have you join us.",
+ "existing_users": false,
+ "new_users": true
+}
+```
+
+**Response Fields:**
+- Same as request fields plus `id` (the newly created blast ID)
+
+**Behavior:**
+- Creates the message blast record
+- Automatically generates individual `Message` records for all users matching the criteria (`existing_users` and/or `new_users`)
+- Returns the created blast with its assigned ID
+
+**Error Responses:**
+- `400 Bad Request`: Invalid parameters
+ ```json
+ {
+ "status": "400",
+ "message": "Parameter [title] is required but not present",
+ "invalid_argument_hash": {
+ "title": "Parameter [title] is required but not present"
+ }
+ }
+ ```
+- `403 Forbidden`: User lacks admin role
+
+**Example Request:**
+```javascript
+const response = await fetch('https://api.example.com/inbox/blast', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${adminToken}`,
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': csrfToken // Required if using cookie auth
+ },
+ body: JSON.stringify({
+ title: 'Welcome New Users',
+ text: 'Welcome to our platform!',
+ existing_users: false,
+ new_users: true
+ })
+});
+```
+
+---
+
+### PATCH /inbox/blast/:id
+
+Updates an existing message blast.
+
+**Authentication:** Required (JWT token)
+
+**Authorization:** Admin role required
+
+**CSRF Protection:** Required if using cookie-based authentication
+
+**URL Parameters:**
+- `id` (integer) - Message Blast ID
+
+**Request Body:**
+```json
+{
+ "title": "Updated Welcome Message",
+ "text": "Updated content here...",
+ "existing_users": true,
+ "new_users": true
+}
+```
+
+**Request Fields:**
+- All fields are optional (only include fields you want to update)
+- `title` (string, optional)
+- `text` (string, optional)
+- `existing_users` (boolean, optional)
+- `new_users` (boolean, optional)
+
+**Response (200 OK):**
+```json
+{
+ "id": 5,
+ "title": "Updated Welcome Message",
+ "text": "Updated content here...",
+ "existing_users": true,
+ "new_users": true
+}
+```
+
+**Response Fields:**
+- Same structure as create response
+
+**Error Responses:**
+- `400 Bad Request`: Invalid parameters or ID not found
+- `403 Forbidden`: User lacks admin role
+
+**Example Request:**
+```javascript
+const response = await fetch(`https://api.example.com/inbox/blast/${blastId}`, {
+ method: 'PATCH',
+ headers: {
+ 'Authorization': `Bearer ${adminToken}`,
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': csrfToken // Required if using cookie auth
+ },
+ body: JSON.stringify({
+ title: 'Updated Title',
+ text: 'Updated content'
+ })
+});
+```
+
+---
+
+### DELETE /inbox/blast/:id
+
+Deletes a message blast.
+
+**Authentication:** Required (JWT token)
+
+**Authorization:** Admin role required
+
+**CSRF Protection:** Required if using cookie-based authentication
+
+**URL Parameters:**
+- `id` (integer) - Message Blast ID
+
+**Response (200 OK):**
+```json
+{
+ "id": 5,
+ "msg": "Message Blast message deleted"
+}
+```
+
+**Error Responses:**
+- `400 Bad Request`: Message Blast ID not found
+ ```json
+ {
+ "status": "400",
+ "message": "MessageBlast ID not found",
+ "invalid_argument_hash": {
+ "id": "MessageBlast ID not found"
+ }
+ }
+ ```
+- `403 Forbidden`: User lacks admin role
+
+**Example Request:**
+```javascript
+const response = await fetch(`https://api.example.com/inbox/blast/${blastId}`, {
+ method: 'DELETE',
+ headers: {
+ 'Authorization': `Bearer ${adminToken}`,
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': csrfToken // Required if using cookie auth
+ }
+});
+```
+
+---
+
+## Pagination
+
+Pagination is available on list endpoints (`GET /inbox/messages` and `GET /inbox/blast`). Command Tower supports three pagination methods:
+
+### Pagination Methods
+
+1. **Page-based**: Uses page numbers (1-indexed)
+2. **Limit-based**: Controls how many items per page
+3. **Cursor-based**: Uses cursor/offset values (takes precedence over page)
+
+### Pagination Parameters
+
+**Via Query String (Preferred):**
+```
+GET /inbox/messages?pagination=true&page=2&limit=20
+```
+
+- `pagination=true` (required) - Enables query-based pagination
+- `page=` (optional) - Page number
+- `limit=` (optional) - Items per page (default: 10)
+- `cursor=` (optional) - Cursor offset (takes precedence over `page`)
+
+**Via Request Body:**
+```json
+{
+ "pagination": {
+ "page": 2,
+ "limit": 20,
+ "cursor": 15
+ }
+}
+```
+
+**Note:** If both `page` and `cursor` are provided, `cursor` takes precedence.
+
+### Pagination Response
+
+The pagination response includes:
+
+```json
+{
+ "pagination": {
+ "current": {
+ "cursor": 0,
+ "limit": 10,
+ "query": "?pagination=true&page=1&limit=10"
+ },
+ "next": {
+ "cursor": 10,
+ "limit": 10,
+ "query": "?pagination=true&page=2&limit=10"
+ },
+ "current_page": 1,
+ "remaining_pages": 2,
+ "total_pages": 3,
+ "count_available": 25
+ }
+}
+```
+
+**Response Fields:**
+- `current`: Current page information
+ - `cursor`: Current cursor/offset value
+ - `limit`: Items per page
+ - `query`: Ready-to-use query string for current page
+- `next`: Next page information (null if on last page)
+ - Same structure as `current`
+- `current_page`: Current page number (1-indexed)
+- `remaining_pages`: Number of pages remaining
+- `total_pages`: Total number of pages
+- `count_available`: Total number of items available
+
+### Pagination Examples
+
+**Example 1: First Page**
+```javascript
+// Request
+GET /inbox/messages?pagination=true&page=1&limit=10
+
+// Response includes:
+{
+ "pagination": {
+ "current_page": 1,
+ "total_pages": 5,
+ "remaining_pages": 4,
+ "next": {
+ "cursor": 10,
+ "query": "?pagination=true&page=2&limit=10"
+ }
+ }
+}
+```
+
+**Example 2: Using Cursor**
+```javascript
+// Request
+GET /inbox/messages?pagination=true&cursor=20&limit=10
+
+// Response includes:
+{
+ "pagination": {
+ "current": {
+ "cursor": 20,
+ "limit": 10
+ },
+ "next": {
+ "cursor": 30,
+ "query": "?pagination=true&cursor=30&limit=10"
+ }
+ }
+}
+```
+
+**Example 3: Last Page**
+```javascript
+// Response includes:
+{
+ "pagination": {
+ "current_page": 5,
+ "total_pages": 5,
+ "remaining_pages": 0,
+ "next": null // No next page
+ }
+}
+```
+
+### Pagination Best Practices
+
+1. **Use query parameters** for GET requests (more cacheable)
+2. **Use cursor-based pagination** for large datasets (more efficient)
+3. **Use the `next.query` field** from the response to fetch the next page
+4. **Default limit is 10** if not specified (configurable in Command Tower)
+5. **Handle empty pagination object** - if pagination is not enabled, the response may not include a `pagination` field
+
+---
+
+## Error Handling
+
+### Error Response Format
+
+All errors follow a consistent format:
+
+```json
+{
+ "status": "400",
+ "message": "Error message description",
+ "invalid_argument_hash": {
+ "field_name": "Field-specific error message"
+ }
+}
+```
+
+### HTTP Status Codes
+
+- **200 OK**: Request successful
+- **400 Bad Request**: Invalid parameters or resource not found
+- **401 Unauthorized**: Missing or invalid JWT token
+- **403 Forbidden**: Valid token but insufficient permissions
+- **412 Precondition Failed**: Email verification required
+
+### Common Error Scenarios
+
+**1. Missing JWT Token:**
+```json
+{
+ "status": "401",
+ "message": "Bearer token missing"
+}
+```
+
+**2. Invalid Message ID:**
+```json
+{
+ "status": "400",
+ "message": "Message ID not found for user",
+ "invalid_argument_hash": {
+ "id": "Message ID not found for user"
+ }
+}
+```
+
+**3. Unauthorized Access (Message Blast):**
+```json
+{
+ "status": "403",
+ "message": "User is not authorized for this action"
+}
+```
+
+**4. Missing Required Parameter:**
+```json
+{
+ "status": "400",
+ "message": "Parameter [title] is required but not present",
+ "invalid_argument_hash": {
+ "title": "Parameter [title] is required but not present"
+ }
+}
+```
+
+**5. Email Verification Required:**
+```json
+{
+ "status": "412",
+ "message": "Email verification required",
+ "meta": {
+ "email_validated": false
+ }
+}
+```
+
+### Error Handling Example
+
+```javascript
+async function fetchMessages(token) {
+ try {
+ const response = await fetch('https://api.example.com/inbox/messages', {
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+
+ switch (response.status) {
+ case 401:
+ // Token expired or invalid - redirect to login
+ handleAuthenticationError();
+ break;
+ case 403:
+ // Insufficient permissions
+ showError('You do not have permission to access this resource');
+ break;
+ case 412:
+ // Email verification required
+ redirectToEmailVerification();
+ break;
+ default:
+ // Other errors
+ showError(error.message || 'An error occurred');
+ }
+ return null;
+ }
+
+ return await response.json();
+ } catch (error) {
+ // Network error
+ showError('Network error. Please check your connection.');
+ return null;
+ }
+}
+```
+
+---
+
+## CSRF Protection
+
+When using **cookie-based authentication**, CSRF protection is required for unsafe HTTP methods (POST, PATCH, DELETE).
+
+### How CSRF Protection Works
+
+1. **CSRF Token Cookie**: When cookie auth is enabled, Command Tower sets a CSRF token in a cookie (readable by JavaScript, not HttpOnly)
+2. **CSRF Token Header**: For unsafe requests, you must read the CSRF token from the cookie and send it in the `X-CSRF-Token` header
+3. **Validation**: Command Tower compares the cookie token with the header token using constant-time comparison
+
+### CSRF Token Cookie Name
+
+The CSRF cookie name is configurable in Command Tower. Typically it follows the pattern:
+- Default: Based on JWT cookie name + `_csrf` suffix
+- Check your Command Tower configuration for the exact cookie name
+
+### Reading CSRF Token
+
+**JavaScript Example:**
+```javascript
+function getCsrfToken() {
+ // Cookie name is configurable - check your Command Tower config
+ const cookieName = 'your_app_csrf_token'; // Replace with actual cookie name
+
+ const cookies = document.cookie.split(';');
+ for (let cookie of cookies) {
+ const [name, value] = cookie.trim().split('=');
+ if (name === cookieName) {
+ return decodeURIComponent(value);
+ }
+ }
+ return null;
+}
+```
+
+**Using a Cookie Library:**
+```javascript
+import Cookies from 'js-cookie';
+
+const csrfToken = Cookies.get('your_app_csrf_token');
+```
+
+### Including CSRF Token in Requests
+
+**Example:**
+```javascript
+const csrfToken = getCsrfToken();
+
+const response = await fetch('https://api.example.com/inbox/messages/ack', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`, // May not be needed if using cookie auth
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': csrfToken // Required for cookie-based auth
+ },
+ credentials: 'include', // Important: Include cookies in request
+ body: JSON.stringify({
+ ids: [1, 2, 3]
+ })
+});
+```
+
+### CSRF Error Responses
+
+**Missing CSRF Token:**
+```json
+{
+ "status": "403",
+ "message": "csrf_missing"
+}
+```
+
+**CSRF Token Mismatch:**
+```json
+{
+ "status": "403",
+ "message": "csrf_mismatch"
+}
+```
+
+### When CSRF is Required
+
+- **Required for**: POST, PATCH, DELETE requests when using cookie-based authentication
+- **Not required for**: GET, HEAD, OPTIONS requests
+- **Not required for**: Header-based authentication (Bearer token in Authorization header)
+
+### CSRF Token Rotation
+
+Command Tower may rotate CSRF tokens:
+- On token refresh (if `rotate_on_reset` is enabled)
+- When token expires and is refreshed
+
+Your application should handle token rotation by reading the CSRF token fresh for each request, rather than caching it.
+
+---
+
+## Token Management
+
+### JWT Token Lifecycle
+
+1. **Obtain Token**: Login endpoint returns JWT token
+2. **Use Token**: Include in `Authorization: Bearer ` header
+3. **Monitor Expiration**: Check `X-Authorization-Expire` response header
+4. **Refresh Token**: Use `X-Authorization-Reset: true` header to request new token
+
+### Token Refresh
+
+Command Tower supports automatic token refresh. Include the `X-Authorization-Reset` header in any authenticated request to get a new token.
+
+**Request:**
+```javascript
+const response = await fetch('https://api.example.com/inbox/messages', {
+ headers: {
+ 'Authorization': `Bearer ${currentToken}`,
+ 'X-Authorization-Reset': 'true', // Request token refresh
+ 'Content-Type': 'application/json'
+ }
+});
+```
+
+**Response Headers:**
+- `X-Authorization-Reset`: Contains the new JWT token
+- `X-Authorization-Expire`: Contains the new expiration timestamp
+
+**Example Token Refresh:**
+```javascript
+async function fetchWithTokenRefresh(token) {
+ const response = await fetch('https://api.example.com/inbox/messages', {
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'X-Authorization-Reset': 'true',
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ // Check for new token
+ const newToken = response.headers.get('X-Authorization-Reset');
+ if (newToken) {
+ // Store new token
+ storeToken(newToken);
+
+ // Update expiration
+ const expiresAt = response.headers.get('X-Authorization-Expire');
+ storeTokenExpiration(expiresAt);
+ }
+
+ return response;
+}
+```
+
+### Token Expiration Monitoring
+
+Monitor the `X-Authorization-Expire` header to know when your token will expire:
+
+```javascript
+function checkTokenExpiration(response) {
+ const expiresAt = response.headers.get('X-Authorization-Expire');
+ if (expiresAt) {
+ const expirationTime = new Date(expiresAt);
+ const now = new Date();
+ const timeUntilExpiry = expirationTime - now;
+
+ // Refresh token if it expires in less than 5 minutes
+ if (timeUntilExpiry < 5 * 60 * 1000) {
+ refreshToken();
+ }
+ }
+}
+```
+
+### Cookie-Based Token Management
+
+If using cookie-based authentication:
+- Token is automatically included in requests (no need for Authorization header)
+- Token is stored in HttpOnly cookie (not accessible to JavaScript)
+- CSRF protection is required for unsafe methods
+- Token refresh updates the cookie automatically
+
+**Example with Cookies:**
+```javascript
+// Token is automatically sent via cookie
+const response = await fetch('https://api.example.com/inbox/messages', {
+ credentials: 'include', // Include cookies
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': getCsrfToken() // For POST/PATCH/DELETE
+ }
+});
+```
+
+---
+
+## Complete Integration Example
+
+Here's a complete example of a messaging client:
+
+```javascript
+class CommandTowerMessagingClient {
+ constructor(baseUrl, token) {
+ this.baseUrl = baseUrl;
+ this.token = token;
+ }
+
+ // Helper to get CSRF token (if using cookie auth)
+ getCsrfToken() {
+ const cookies = document.cookie.split(';');
+ for (let cookie of cookies) {
+ const [name, value] = cookie.trim().split('=');
+ if (name.includes('csrf')) {
+ return decodeURIComponent(value);
+ }
+ }
+ return null;
+ }
+
+ // Helper to make authenticated requests
+ async request(endpoint, options = {}) {
+ const headers = {
+ 'Authorization': `Bearer ${this.token}`,
+ 'Content-Type': 'application/json',
+ ...options.headers
+ };
+
+ // Add CSRF token for unsafe methods if using cookie auth
+ if (['POST', 'PATCH', 'DELETE'].includes(options.method)) {
+ const csrfToken = this.getCsrfToken();
+ if (csrfToken) {
+ headers['X-CSRF-Token'] = csrfToken;
+ }
+ }
+
+ const response = await fetch(`${this.baseUrl}${endpoint}`, {
+ ...options,
+ headers,
+ credentials: 'include' // Include cookies if using cookie auth
+ });
+
+ // Check for token refresh
+ const newToken = response.headers.get('X-Authorization-Reset');
+ if (newToken) {
+ this.token = newToken;
+ // Store new token in your app's state management
+ }
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.message || 'Request failed');
+ }
+
+ return response.json();
+ }
+
+ // Get messages with pagination
+ async getMessages(page = 1, limit = 10) {
+ return this.request(
+ `/inbox/messages?pagination=true&page=${page}&limit=${limit}`
+ );
+ }
+
+ // Get single message
+ async getMessage(id) {
+ return this.request(`/inbox/messages/${id}`);
+ }
+
+ // Acknowledge messages
+ async acknowledgeMessages(ids) {
+ return this.request('/inbox/messages/ack', {
+ method: 'POST',
+ body: JSON.stringify({ ids })
+ });
+ }
+
+ // Delete messages
+ async deleteMessages(ids) {
+ return this.request('/inbox/messages/delete', {
+ method: 'POST',
+ body: JSON.stringify({ ids })
+ });
+ }
+
+ // Admin: Get message blasts
+ async getMessageBlasts(page = 1, limit = 10) {
+ return this.request(
+ `/inbox/blast?pagination=true&page=${page}&limit=${limit}`
+ );
+ }
+
+ // Admin: Get single message blast
+ async getMessageBlast(id) {
+ return this.request(`/inbox/blast/${id}`);
+ }
+
+ // Admin: Create message blast
+ async createMessageBlast({ title, text, existing_users, new_users }) {
+ return this.request('/inbox/blast', {
+ method: 'POST',
+ body: JSON.stringify({ title, text, existing_users, new_users })
+ });
+ }
+
+ // Admin: Update message blast
+ async updateMessageBlast(id, updates) {
+ return this.request(`/inbox/blast/${id}`, {
+ method: 'PATCH',
+ body: JSON.stringify(updates)
+ });
+ }
+
+ // Admin: Delete message blast
+ async deleteMessageBlast(id) {
+ return this.request(`/inbox/blast/${id}`, {
+ method: 'DELETE'
+ });
+ }
+}
+
+// Usage example
+const client = new CommandTowerMessagingClient(
+ 'https://api.example.com',
+ userJwtToken
+);
+
+// Get user's messages
+const messages = await client.getMessages(1, 20);
+console.log(`Found ${messages.count} messages`);
+
+// View a message (automatically marks as viewed)
+const message = await client.getMessage(messages.entities[0].id);
+console.log(message.text);
+
+// Acknowledge multiple messages
+await client.acknowledgeMessages([1, 2, 3]);
+
+// Admin: Create a message blast
+await client.createMessageBlast({
+ title: 'System Update',
+ text: 'We have updated our system...',
+ existing_users: true,
+ new_users: false
+});
+```
+
+---
+
+## Summary
+
+### Key Points
+
+1. **Authentication**: All endpoints require JWT token in `Authorization: Bearer ` header
+2. **Authorization**: Message blasts require admin role
+3. **Messages**: User-scoped, individual notifications
+4. **Message Blasts**: Admin-created templates that generate messages for users
+5. **Pagination**: Available on list endpoints via query params or request body
+6. **CSRF Protection**: Required for POST/PATCH/DELETE when using cookie-based auth
+7. **Token Refresh**: Use `X-Authorization-Reset: true` header to refresh tokens
+8. **Error Handling**: Consistent error format with status codes and messages
+
+### Endpoint Quick Reference
+
+**Messages (User):**
+- `GET /inbox/messages` - List messages (paginated)
+- `GET /inbox/messages/:id` - Get single message (auto-marks as viewed)
+- `POST /inbox/messages/ack` - Mark messages as viewed
+- `POST /inbox/messages/delete` - Delete messages
+
+**Message Blasts (Admin):**
+- `GET /inbox/blast` - List blasts (paginated)
+- `GET /inbox/blast/:id` - Get single blast
+- `POST /inbox/blast` - Create blast
+- `PATCH /inbox/blast/:id` - Update blast
+- `DELETE /inbox/blast/:id` - Delete blast
+
+For more information about Command Tower authentication and configuration, see the [Authentication Guide](authentication.md) and [Cookie Authentication Guide](cookie_authentication_guide.md).
diff --git a/docs/password_reset_workflow.md b/docs/password_reset_workflow.md
new file mode 100644
index 0000000..47a1c01
--- /dev/null
+++ b/docs/password_reset_workflow.md
@@ -0,0 +1,955 @@
+# Password Reset (Forgot Password) Endpoint
+
+## Table of Contents
+
+1. [Overview](#overview)
+2. [High-Level Details](#high-level-details)
+3. [Request Schema](#request-schema)
+4. [Response Schema](#response-schema)
+5. [Error Scenarios & Status Codes](#error-scenarios--status-codes)
+6. [Status Code Summary](#status-code-summary)
+7. [Security Considerations](#security-considerations)
+8. [Implementation Notes](#implementation-notes)
+9. [Example Requests/Responses](#example-requestsresponses)
+10. [Related Endpoints](#related-endpoints)
+
+---
+
+## Overview
+
+The forgot password endpoint allows users to request a password reset by email. This endpoint generates a secure reset token and sends it to the user's email address. The endpoint follows security best practices by not revealing whether an email exists in the system, preventing user enumeration attacks.
+
+### Key Characteristics
+
+- **Public Endpoint**: No authentication required
+- **Email-Based**: Uses email address to identify the user
+- **Token Generation**: Creates secure, time-limited reset tokens
+- **Email Delivery**: Sends reset link/token via email
+- **Security-First**: Always returns success (even if email doesn't exist) to prevent enumeration
+- **Rate Limiting**: Should be protected against abuse
+
+---
+
+## High-Level Details
+
+### Purpose
+
+The forgot password endpoint enables users who have forgotten their password to request a password reset. The system:
+
+1. Accepts an email address from the user
+2. Validates the email format
+3. If valid, generates a secure reset token
+4. Stores the token with an expiration timestamp
+5. Sends an email containing the reset link/token
+6. Returns a success response (regardless of whether the email exists)
+
+### Endpoint Details
+
+- **Route**: `POST /auth/password/forgot/send` - Request password reset email
+- **Route**: `POST /auth/password/forgot/validate` - Validate reset token
+- **Route**: `POST /auth/password/forgot/reset` - Reset password with token
+- **Authentication**: Not required (public endpoints)
+- **Controller**: `CommandTower::Auth::PlainTextController`
+ - `password_forgot_send_post`
+ - `password_forgot_validate_post`
+ - `password_forgot_reset_post`
+- **Services**: `CommandTower::LoginStrategy::PlainText::PasswordReset`
+ - `Send`
+ - `Validate`
+ - `Reset`
+
+### Related: authenticated password change
+
+Recovery does **not** require a JWT and does **not** rotate `verifier_token` after a successful reset (known gap vs authenticated change). For signed-in users changing their password, use **`POST /auth/password/change`** (`ChangePassword`) instead — that path proves the current password, updates the digest, and rotates the verifier atomically so all sessions end. See [change_password_workflow.md](change_password_workflow.md).
+
+### Request Flow
+
+1. **Client Request**: User submits email address
+2. **Email Validation**: System validates email format
+3. **User Lookup**: System checks if user exists (silently, no error if not found)
+4. **Token Generation**: If user exists, generates secure reset token
+5. **Token Storage**: Stores token with expiration timestamp in database
+6. **Email Delivery**: Sends email with reset link/token
+7. **Response**: Returns success message (same response whether user exists or not)
+
+### Response Behavior
+
+- **Always Returns Success**: Returns 200/201 on valid request format (security best practice)
+- **No User Enumeration**: Does not reveal whether email exists in system
+- **Email Delivery Failures**: Only returns 500 if email delivery fails (and user exists)
+- **Generic Message**: Uses generic success message that doesn't reveal user existence
+
+### Security Considerations
+
+- **Rate Limiting**: Should be implemented to prevent abuse
+- **Token Expiration**: Tokens should expire (typically 1-24 hours)
+- **Single-Use Tokens**: Tokens should be invalidated after use
+- **No User Enumeration**: Same response for existing/non-existing emails
+- **Secure Token Generation**: Uses cryptographically secure random token generation
+
+### Related Endpoints
+
+- **Password Reset Send**: `POST /auth/password/forgot/send` - Request password reset email
+- **Password Reset Validate**: `POST /auth/password/forgot/validate` - Validate reset token
+- **Password Reset**: `POST /auth/password/forgot/reset` - Reset password with token
+
+---
+
+## Email Requirement for Enhanced Security
+
+Command Tower supports an optional email requirement feature that adds an additional security layer to the password reset flow. When enabled, users must provide both the reset token and their email address when validating or resetting their password.
+
+### Security Benefits
+
+- **Prevents Brute Force Attacks**: Attackers must know both the token and the correct email address, making brute force attacks significantly more difficult
+- **Additional Verification Layer**: Even if a token is compromised, the attacker must also know the user's email address
+- **Backward Compatible**: The feature defaults to `false`, ensuring existing implementations continue to work without changes
+
+### How It Works
+
+1. **Configuration**: Enable the feature by setting `require_email: true` in the password reset configuration
+2. **Email Template**: When enabled, the reset email link automatically includes the email as a query parameter
+3. **Validation**: Both validate and reset endpoints check:
+ - If `require_email` is enabled and email is missing → returns 400 "Email is required"
+ - If email is provided → verifies it matches the user's email associated with the token
+ - If email doesn't match → returns 401 "Invalid token" (same message as invalid token for security)
+
+### Email Normalization
+
+Email addresses are normalized before comparison:
+- Converted to lowercase
+- Whitespace trimmed
+- This ensures "User@Example.com" matches "user@example.com"
+
+### Error Responses
+
+When `require_email: true`:
+- Missing email: `400 Bad Request` with message "Email is required"
+- Email mismatch: `401 Unauthorized` with message "Invalid token" (generic message for security)
+
+---
+
+## Request Schema
+
+**Endpoint**: `POST /auth/password/forgot/send`
+
+**Headers**:
+```
+Content-Type: application/json
+```
+
+**Request Body**:
+```json
+{
+ "email": "user@example.com"
+}
+```
+
+**Schema Definition** (following command_tower patterns):
+```ruby
+# lib/command_tower/schema/auth/plain_text/password_forgot/send/request.rb
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module PasswordForgot
+ module Send
+ class Request < JsonSchematize::Generator
+ schema_default option: :dig_type, value: :string
+
+ add_field name: :email, type: String, required: true
+ end
+ end
+ end
+ end
+ end
+ end
+end
+```
+
+**Field Validation**:
+- `email`: Required, must be valid email format, case-insensitive
+
+**Field Types**:
+- `email`: String (required) - User's email address
+
+---
+
+## Response Schema
+
+**Success Response** (200 OK or 201 Created):
+
+```json
+{
+ "message": "If an account exists with that email, a password reset link has been sent."
+}
+```
+
+**Schema Definition**:
+```ruby
+# lib/command_tower/schema/auth/plain_text/password_forgot/send/response.rb
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module PasswordForgot
+ module Send
+ class Response < JsonSchematize::Generator
+ add_field name: :message, type: String
+ end
+ end
+ end
+ end
+ end
+ end
+end
+```
+
+**Response Schema Fields**:
+- `message`: String (required) - Success message (generic, doesn't reveal if email exists)
+
+**Response Headers**:
+- No special headers required (this is a public endpoint)
+
+---
+
+## Error Scenarios & Status Codes
+
+### 1. 400 Bad Request - Invalid Arguments
+
+**Scenario**: Missing or invalid email format
+
+**Response**:
+```json
+{
+ "message": "Invalid request parameters",
+ "status": "400",
+ "invalid_arguments": [
+ {
+ "schema": {...},
+ "argument": "email",
+ "argument_type": "String",
+ "reason": "Email is required and must be a valid email address"
+ }
+ ],
+ "invalid_argument_keys": ["email"]
+}
+```
+
+**Schema**: `CommandTower::Schema::Error::InvalidArgumentResponse`
+
+**Triggers**:
+- Email field missing from request body
+- Email format invalid (doesn't match email regex pattern)
+- Email is empty string
+- Email is null
+
+**Example Request** (Invalid):
+```json
+{
+ "email": "not-an-email"
+}
+```
+
+**Example Request** (Missing Field):
+```json
+{}
+```
+
+---
+
+### 2. 400 Bad Request - Generic Error
+
+**Scenario**: Request body parsing error or other client-side issues
+
+**Response**:
+```json
+{
+ "status": "400",
+ "message": "Invalid request format"
+}
+```
+
+**Schema**: `CommandTower::Schema::Error::Base`
+
+**Triggers**:
+- Malformed JSON in request body
+- Invalid Content-Type header
+- Request body parsing failure
+- Other client-side validation errors
+
+---
+
+### 3. 429 Too Many Requests - Rate Limiting
+
+**Scenario**: Too many password reset requests from same IP/email
+
+**Response**:
+```json
+{
+ "status": "429",
+ "message": "Too many password reset requests. Please try again later"
+}
+```
+
+**Schema**: `CommandTower::Schema::Error::Base`
+
+**Triggers**:
+- Rate limiting middleware detects too many requests
+- Too many requests from same email address within time window
+- Too many requests from same IP address within time window
+- Global rate limit exceeded
+
+**Rate Limiting Considerations**:
+- Should be implemented at application level (per email address)
+- Should be implemented at IP level (per IP address)
+- Should be implemented at global level (total requests per time window)
+- Typical limits: 3-5 requests per email per hour, 10-20 requests per IP per hour
+
+---
+
+## Status Code Summary
+
+### Send Endpoint (`POST /auth/password/forgot/send`)
+
+| Status Code | Scenario | Response Schema | Notes |
+|------------|----------|----------------|-------|
+| **200 OK** | Success (email sent or user doesn't exist) | `PasswordForgot::Send::Response` | Always returns 200, never 500 |
+| **400 Bad Request** | Invalid email format, missing email, malformed request | `InvalidArgumentResponse` or `Error::Base` | Client-side validation errors |
+
+### Validate Endpoint (`POST /auth/password/forgot/validate`)
+
+| Status Code | Scenario | Response Schema | Notes |
+|------------|----------|----------------|-------|
+| **200 OK** | Token is valid | `PasswordForgot::Validate::Response` | Returns valid=true and expires_at |
+| **400 Bad Request** | Missing token, missing email (when require_email: true) | `InvalidArgumentResponse` or `Error::Base` | Client-side validation error |
+| **401 Unauthorized** | Invalid, expired, used token, or email mismatch | `Error::Base` | Generic "Invalid token" message |
+
+### Reset Endpoint (`POST /auth/password/forgot/reset`)
+
+| Status Code | Scenario | Response Schema | Notes |
+|------------|----------|----------------|-------|
+| **200 OK** | Password reset successful | `PasswordForgot::Reset::Response` | Password updated |
+| **400 Bad Request** | Missing token, missing email (when require_email: true), password validation errors, password mismatch | `InvalidArgumentResponse` or `Error::Base` | Client-side validation errors |
+| **401 Unauthorized** | Invalid, expired, used token, or email mismatch | `Error::Base` | Generic "Invalid token" message |
+
+---
+
+## Security Considerations
+
+### 1. User Enumeration Prevention
+
+**Critical Security Practice**: Always return the same success response (200/201) regardless of whether the email exists in the system. This prevents attackers from determining which email addresses are registered.
+
+**Implementation**:
+- If user exists: Generate token, store it, send email, return success
+- If user doesn't exist: Return success without generating token or sending email
+- Use generic message: "If an account exists with that email, a password reset link has been sent."
+
+### 2. Rate Limiting
+
+**Purpose**: Prevent abuse and email spam
+
+**Implementation Levels**:
+1. **Per Email Address**: Limit requests per email (e.g., 3-5 per hour)
+2. **Per IP Address**: Limit requests per IP (e.g., 10-20 per hour)
+3. **Global**: Limit total requests across all sources
+
+**Configuration**:
+- Should be configurable via CommandTower configuration
+- Should use Redis or similar for distributed rate limiting
+- Should return 429 status when limit exceeded
+
+### 3. Token Generation
+
+**Requirements**:
+- Use cryptographically secure random token generation
+- Token should be sufficiently long (e.g., 32+ characters)
+- Token should be URL-safe
+- Token should be unique (check for collisions)
+
+**Example Implementation**:
+```ruby
+SecureRandom.alphanumeric(32) # or similar secure method
+```
+
+### 4. Token Storage
+
+**Database Schema** (in `user_secrets` table or similar):
+- `secret`: String - The reset token
+- `user_id`: Foreign Key - Links to the user
+- `death_time`: Timestamp - Token expiration time
+- `reason`: String - Identifies this as a password reset token
+- `use_count`: Integer - Number of times token has been used
+- `use_count_max`: Integer - Maximum allowed uses (typically 1)
+
+**Token Expiration**:
+- Default: 1 hour (configurable)
+- Should be configurable via CommandTower configuration
+- Expired tokens should be automatically cleaned up
+
+### 5. Email Security
+
+**Email Content**:
+- Should include reset link with token
+- Should clearly show expiration time
+- Should include security warning if user didn't request reset
+- Should not include sensitive information beyond the token
+
+**Email Template**:
+- Should be configurable via CommandTower configuration
+- Should use secure email delivery (TLS/SSL)
+- Should include app branding and clear instructions
+
+### 6. Token Invalidation
+
+**Single-Use Tokens**:
+- Tokens should be invalidated after successful password reset
+- Tokens should be invalidated if expired
+- Tokens should be invalidated if user requests a new reset
+
+**Implementation**:
+- Set `use_count_max` to 1
+- Increment `use_count` when token is used
+- Check `use_count < use_count_max` during validation
+
+### 7. Email Requirement Feature
+
+**Optional Email Verification**:
+- When `require_email` config is enabled, users must provide both token and email
+- Email is normalized (lowercase, trimmed) before comparison
+- Email must match the user's email associated with the token
+- Provides additional security layer against brute force attacks
+
+**Implementation**:
+- Email validation is handled by `EmailValidationHelper` module
+- Shared logic used by both Validate and Reset services
+- Email parameter is optional in request schemas
+- When required but missing, returns 400 "Email is required"
+- When provided but mismatched, returns 401 "Invalid token" (generic for security)
+
+---
+
+## Implementation Notes
+
+### 1. Service Implementation Pattern
+
+The service should follow the command_tower service pattern:
+
+```ruby
+# app/services/command_tower/login_strategy/plain_text/password_reset/request.rb
+module CommandTower::LoginStrategy::PlainText::PasswordReset
+ class Request < CommandTower::ServiceBase
+ on_argument_validation :fail_early
+
+ validate :email, is_a: String, required: true
+
+ def call
+ # Find user by email (silently, don't fail if not found)
+ user = User.find_by(email: email.downcase.strip)
+
+ # If user exists, generate token and send email
+ if user
+ result = GenerateToken.(user: user)
+ if result.success?
+ SendEmail.(user: user, token: result.token)
+ else
+ # Only fail if email delivery fails
+ context.fail!(msg: "Unable to send password reset email. Please try again later", status: 500)
+ return
+ end
+ end
+
+ # Always return success (security: don't reveal if user exists)
+ context.message = "If an account exists with that email, a password reset link has been sent."
+ end
+ end
+end
+```
+
+### 2. Controller Implementation Pattern
+
+The controller should follow the command_tower controller pattern:
+
+```ruby
+# In CommandTower::Auth::PlainTextController
+def forgot_password_post
+ result = CommandTower::LoginStrategy::PlainText::PasswordReset::Request.(**forgot_password_params)
+ if result.success?
+ schema = CommandTower::Schema::Auth::PlainText::PasswordForgot::Response.new(
+ message: result.message
+ )
+ status = 200
+ schema_succesful!(status:, schema:)
+ else
+ if result.invalid_arguments
+ invalid_arguments!(
+ status: 400,
+ message: result.msg,
+ argument_object: result.invalid_argument_hash,
+ schema: CommandTower::Schema::Auth::PlainText::PasswordForgot::Request
+ )
+ else
+ schema = CommandTower::Schema::Error::Base.new(status: result.status || 500, message: result.msg)
+ status = result.status || 500
+ render(json: schema.to_h, status:)
+ end
+ end
+end
+
+private
+
+def forgot_password_params
+ {
+ email: params[:email]
+ }
+end
+```
+
+### 3. Route Configuration
+
+Add route to `config/routes.rb`:
+
+```ruby
+scope "auth" do
+ constraints(->(_req) { CommandTower.config.login.plain_text.enable? }) do
+ post "/password/forgot", to: "command_tower/auth/plain_text#forgot_password_post", as: :"#{append_to_ass}_auth_password_forgot_post"
+ end
+end
+```
+
+### 4. Email Template
+
+The email template should include:
+- Reset link with token: Uses `reset_password_path` config appended to `composed_url`
+ - Format when `require_email: false`: `{composed_url}{reset_password_path}?token={token}`
+ - Format when `require_email: true`: `{composed_url}{reset_password_path}?token={token}&email={email}`
+- Alternative reset URL link: Shows the base reset password URL for manual entry
+- Expiration time: "This link will expire in {token_valid_for}"
+- Security warning: "If you didn't request this, please ignore this email"
+- App branding and clear instructions
+
+The email template automatically:
+- Includes email parameter in URL when `require_email` is enabled
+- Uses `reset_password_path` config value instead of hardcoded path
+- URL-encodes email parameter for safe transmission
+
+### 5. Token Generation Service
+
+```ruby
+# app/services/command_tower/login_strategy/plain_text/password_reset/generate_token.rb
+module CommandTower::LoginStrategy::PlainText::PasswordReset
+ class GenerateToken < CommandTower::ServiceBase
+ validate :user, is_a: User, required: true
+
+ def call
+ # Generate secure token
+ token = SecureRandom.alphanumeric(32)
+
+ # Calculate expiration (configurable, default 1 hour)
+ expires_at = CommandTower.config.password_reset.token_valid_for.from_now
+
+ # Store token in user_secrets
+ user_secret = UserSecret.create!(
+ user: user,
+ secret: token,
+ death_time: expires_at,
+ reason: "password_reset",
+ use_count: 0,
+ use_count_max: 1
+ )
+
+ context.token = token
+ context.expires_at = expires_at
+ end
+ end
+end
+```
+
+### 6. Email Sending Service
+
+The `Send` service automatically:
+- Passes email, `require_email` flag, and `reset_password_path` to the mailer
+- Mailer makes these available to the email template as instance variables
+- Template conditionally includes email in URL based on `require_email` setting
+
+### 7. Email Validation Helper
+
+A shared helper module (`EmailValidationHelper`) provides email validation logic used by both Validate and Reset services:
+
+```ruby
+# app/services/command_tower/login_strategy/plain_text/password_reset/email_validation_helper.rb
+module CommandTower::LoginStrategy::PlainText::PasswordReset
+ module EmailValidationHelper
+ def self.validate_email_with_token(email:, token:, context:, access_count: false)
+ # Checks if require_email is enabled and email is missing
+ # If email provided, verifies token and compares emails (normalized)
+ # Returns hash with :user and :verify_result
+ end
+ end
+end
+```
+
+**Usage in Services**:
+- Both `Validate` and `Reset` services call this helper
+- Helper handles email requirement checking and email matching
+- Returns verification result to avoid duplicate token verification
+
+---
+
+## Example Requests/Responses
+
+### Example 1: Successful Request (User Exists)
+
+**Request**:
+```bash
+curl -X POST https://api.example.com/auth/password/forgot/send \
+ -H "Content-Type: application/json" \
+ -d '{"email": "user@example.com"}'
+```
+
+**Response** (200 OK):
+```json
+{
+ "message": "If an account exists with that email, a password reset link has been sent."
+}
+```
+
+**What Happens**:
+1. System validates email format ✓
+2. System finds user with email "user@example.com" ✓
+3. System generates secure reset token
+4. System stores token with 1-hour expiration
+5. System sends email with reset link
+6. System returns success response
+
+---
+
+### Example 2: Successful Request (User Doesn't Exist)
+
+**Request**:
+```bash
+curl -X POST https://api.example.com/auth/password/forgot/send \
+ -H "Content-Type: application/json" \
+ -d '{"email": "nonexistent@example.com"}'
+```
+
+**Response** (200 OK):
+```json
+{
+ "message": "If an account exists with that email, a password reset link has been sent."
+}
+```
+
+**What Happens**:
+1. System validates email format ✓
+2. System checks for user with email "nonexistent@example.com" ✗ (not found)
+3. System returns success response (same as if user exists)
+4. No token generated, no email sent (but client doesn't know this)
+
+**Security Note**: This prevents user enumeration attacks.
+
+---
+
+### Example 3: Invalid Email Format
+
+**Request**:
+```bash
+curl -X POST https://api.example.com/auth/password/forgot/send \
+ -H "Content-Type: application/json" \
+ -d '{"email": "not-an-email"}'
+```
+
+**Response** (400 Bad Request):
+```json
+{
+ "message": "Invalid request parameters",
+ "status": "400",
+ "invalid_arguments": [
+ {
+ "schema": {...},
+ "argument": "email",
+ "argument_type": "String",
+ "reason": "Email is required and must be a valid email address"
+ }
+ ],
+ "invalid_argument_keys": ["email"]
+}
+```
+
+---
+
+### Example 4: Missing Email Field
+
+**Request**:
+```bash
+curl -X POST https://api.example.com/auth/password/forgot/send \
+ -H "Content-Type: application/json" \
+ -d '{}'
+```
+
+**Response** (400 Bad Request):
+```json
+{
+ "message": "Invalid request parameters",
+ "status": "400",
+ "invalid_arguments": [
+ {
+ "schema": {...},
+ "argument": "email",
+ "argument_type": "String",
+ "reason": "Email is required"
+ }
+ ],
+ "invalid_argument_keys": ["email"]
+}
+```
+
+---
+
+### Example 5: Validate Token
+
+**Request** (without email - when require_email: false):
+```bash
+curl -X POST https://api.example.com/auth/password/forgot/validate \
+ -H "Content-Type: application/json" \
+ -d '{"token": "reset_token_from_email"}'
+```
+
+**Request** (with email - when require_email: true):
+```bash
+curl -X POST https://api.example.com/auth/password/forgot/validate \
+ -H "Content-Type: application/json" \
+ -d '{
+ "token": "reset_token_from_email",
+ "email": "user@example.com"
+ }'
+```
+
+**Response** (200 OK):
+```json
+{
+ "valid": true,
+ "expires_at": "2025-01-16T10:00:00Z"
+}
+```
+
+**Response** (400 Bad Request - Email Required):
+```json
+{
+ "status": "400",
+ "message": "Email is required"
+}
+```
+
+**Response** (401 Unauthorized - Invalid Token or Email Mismatch):
+```json
+{
+ "status": "401",
+ "message": "Invalid token"
+}
+```
+
+---
+
+### Example 6: Reset Password
+
+**Request** (without email - when require_email: false):
+```bash
+curl -X POST https://api.example.com/auth/password/forgot/reset \
+ -H "Content-Type: application/json" \
+ -d '{
+ "token": "reset_token_from_email",
+ "password": "new_password123",
+ "password_confirmation": "new_password123"
+ }'
+```
+
+**Request** (with email - when require_email: true):
+```bash
+curl -X POST https://api.example.com/auth/password/forgot/reset \
+ -H "Content-Type: application/json" \
+ -d '{
+ "token": "reset_token_from_email",
+ "email": "user@example.com",
+ "password": "new_password123",
+ "password_confirmation": "new_password123"
+ }'
+```
+
+**Response** (200 OK):
+```json
+{
+ "message": "Password has been successfully reset"
+}
+```
+
+**Response** (400 Bad Request - Email Required):
+```json
+{
+ "status": "400",
+ "message": "Email is required"
+}
+```
+
+**Response** (401 Unauthorized - Invalid Token or Email Mismatch):
+```json
+{
+ "status": "401",
+ "message": "Invalid token"
+}
+```
+
+**Response** (400 Bad Request - Password Mismatch):
+```json
+{
+ "message": "Invalid request parameters",
+ "status": "400",
+ "invalid_arguments": [
+ {
+ "argument": "password_confirmation",
+ "msg": "doesn't match Password"
+ }
+ ],
+ "invalid_argument_keys": ["password_confirmation"]
+}
+```
+
+---
+
+## Related Endpoints
+
+### Password Reset Validate
+
+**Endpoint**: `POST /auth/password/forgot/validate`
+
+**Purpose**: Check if a reset token is valid before showing the reset form
+
+**Request** (without email):
+```json
+{
+ "token": "reset_token_from_email"
+}
+```
+
+**Request** (with email - when require_email: true):
+```json
+{
+ "token": "reset_token_from_email",
+ "email": "user@example.com"
+}
+```
+
+**Response** (200 OK):
+```json
+{
+ "valid": true,
+ "expires_at": "2025-01-16T10:00:00Z"
+}
+```
+
+**Error Responses**:
+- `400 Bad Request`: Missing token, missing email (when require_email: true)
+- `401 Unauthorized`: Invalid, expired, used token, or email mismatch (generic message)
+
+---
+
+### Password Reset
+
+**Endpoint**: `POST /auth/password/forgot/reset`
+
+**Purpose**: Reset password using the token from the email
+
+**Request** (without email):
+```json
+{
+ "token": "reset_token_from_email",
+ "password": "new_password",
+ "password_confirmation": "new_password"
+}
+```
+
+**Request** (with email - when require_email: true):
+```json
+{
+ "token": "reset_token_from_email",
+ "email": "user@example.com",
+ "password": "new_password",
+ "password_confirmation": "new_password"
+}
+```
+
+**Response** (200 OK):
+```json
+{
+ "message": "Password has been successfully reset"
+}
+```
+
+**Error Responses**:
+- `400 Bad Request`: Missing token, missing email (when require_email: true), password validation errors, password mismatch
+- `401 Unauthorized`: Invalid, expired, used token, or email mismatch (generic message)
+
+---
+
+## Configuration
+
+### Password Reset Configuration
+
+Password reset should be configurable in the CommandTower initializer:
+
+```ruby
+CommandTower.configure do |config|
+ config.login.plain_text.password_reset.enabled = true
+ config.login.plain_text.password_reset.token_valid_for = 1.hour
+ config.login.plain_text.password_reset.token_length = 32
+ config.login.plain_text.password_reset.custom_template_name = "password_reset"
+ config.login.plain_text.password_reset.require_email = false # Enable for enhanced security
+ config.login.plain_text.password_reset.reset_password_path = "/reset-password" # Frontend reset path
+
+ # Rate limiting
+ config.password_reset.rate_limit.per_email = 3 # requests per hour
+ config.password_reset.rate_limit.per_ip = 10 # requests per hour
+end
+```
+
+### Configuration Options
+
+- `enabled`: Boolean - Master switch for password reset feature (default: `true`)
+- `token_valid_for`: ActiveSupport::Duration - Token expiration time (default: `10.minutes`, max: `24.hours`)
+- `token_length`: Integer - Length of reset token (default: `32`, range: 16-64)
+- `custom_template_name`: String or Nil - Email template name (default: `nil`, uses "reset_password")
+- `require_email`: Boolean - Require email with token for validation/reset (default: `false`)
+- `reset_password_path`: String - Path to frontend reset password page (default: `"/reset-password"`)
+ - This path is appended to `config.app.composed_url` to form the full URL in email templates
+- `rate_limit.per_email`: Integer - Max requests per email per hour
+- `rate_limit.per_ip`: Integer - Max requests per IP per hour
+
+---
+
+## Summary
+
+The forgot password endpoint provides a secure way for users to request password resets:
+
+1. **Public Endpoint**: No authentication required
+2. **Email-Based**: Uses email to identify users
+3. **Secure**: Prevents user enumeration by always returning success
+4. **Token-Based**: Generates secure, time-limited reset tokens
+5. **Rate Limited**: Protected against abuse
+6. **Error Handling**: Comprehensive error scenarios with appropriate status codes
+
+**Key Security Features**:
+- No user enumeration (same response for all valid emails)
+- Secure token generation
+- Token expiration
+- Single-use tokens
+- Rate limiting
+- Secure email delivery
+
+**Status Codes**:
+- `200 OK` / `201 Created`: Success (email sent or user doesn't exist)
+- `400 Bad Request`: Invalid email format or missing field
+- `429 Too Many Requests`: Rate limit exceeded
+- `500 Internal Server Error`: Email delivery failure
+
+This endpoint follows command_tower patterns and integrates seamlessly with the existing authentication system.
diff --git a/lib/command_tower.rb b/lib/command_tower.rb
index 3957de3..674e90b 100644
--- a/lib/command_tower.rb
+++ b/lib/command_tower.rb
@@ -3,6 +3,8 @@
require "command_tower/version"
require "command_tower/engine"
require "command_tower/configuration/config"
+require "command_tower/jwt/authorization_helper"
+require "command_tower/jwt/csrf_helper"
module CommandTower
def self.config
diff --git a/lib/command_tower/configuration/application/config.rb b/lib/command_tower/configuration/application/config.rb
index c79d11d..4489842 100644
--- a/lib/command_tower/configuration/application/config.rb
+++ b/lib/command_tower/configuration/application/config.rb
@@ -21,12 +21,12 @@ class Config
add_composer :url,
allowed: String,
- default: ENV.fetch("command_tower_URL", "http://localhost"),
+ default: ENV.fetch("COMMAND_TOWER_FRONTEND_URL", "http://localhost"),
desc: "When composing SSO's or verification URL's, this is the URL for the application"
add_composer :port,
allowed: [String, NilClass],
- default: ENV.fetch("command_tower_PORT", nil),
+ default: ENV.fetch("COMMAND_TOWER_FRONTEND_PORT", nil),
desc: "When composing SSO's or verification URL's, this is the PORT for the application"
add_composer :composed_url,
diff --git a/lib/command_tower/configuration/base.rb b/lib/command_tower/configuration/base.rb
index acf1a95..91fa858 100644
--- a/lib/command_tower/configuration/base.rb
+++ b/lib/command_tower/configuration/base.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require "class_composer"
-require "pry"
module CommandTower
module Configuration
diff --git a/lib/command_tower/configuration/config.rb b/lib/command_tower/configuration/config.rb
index 8c2a8a7..eaecbfe 100644
--- a/lib/command_tower/configuration/config.rb
+++ b/lib/command_tower/configuration/config.rb
@@ -8,6 +8,7 @@
require "command_tower/configuration/base"
require "command_tower/configuration/email/config"
require "command_tower/configuration/jwt/config"
+require "command_tower/configuration/jwt/cookie/config"
require "command_tower/configuration/login/config"
require "command_tower/configuration/otp/config"
require "command_tower/configuration/pagination/config"
@@ -80,4 +81,3 @@ class Config < ::CommandTower::Configuration::Base
end
end
end
-
diff --git a/lib/command_tower/configuration/jwt/config.rb b/lib/command_tower/configuration/jwt/config.rb
index fa92668..ee04b17 100644
--- a/lib/command_tower/configuration/jwt/config.rb
+++ b/lib/command_tower/configuration/jwt/config.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+require "command_tower/configuration/jwt/cookie/config"
+
module CommandTower
module Configuration
module Jwt
@@ -16,6 +18,11 @@ class Config < ::CommandTower::Configuration::Base
allowed: String,
default: ENV.fetch("SECRET_KEY_BASE","Thi$IsASeccretIwi::CH&ang3"),
default_shown: "ENV.fetch(\"SECRET_KEY_BASE\",\"Thi$IsASeccretIwi::CH&ang3\")"
+
+ add_composer_blocking :cookie,
+ desc: "HttpOnly cookie configuration for JWT token persistence",
+ composer_class: Cookie::Config,
+ enable_attr: :enabled
end
end
end
diff --git a/lib/command_tower/configuration/jwt/cookie/config.rb b/lib/command_tower/configuration/jwt/cookie/config.rb
new file mode 100644
index 0000000..6d6c36c
--- /dev/null
+++ b/lib/command_tower/configuration/jwt/cookie/config.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require "command_tower/configuration/jwt/cookie/csrf/config"
+
+module CommandTower
+ module Configuration
+ module Jwt
+ module Cookie
+ class Config < ::CommandTower::Configuration::Base
+ include ClassComposer::Generator
+
+ add_composer :enabled,
+ desc: "Enable HttpOnly cookie support for JWT tokens. When disabled, cookies are never set, read, or refreshed",
+ allowed: [TrueClass, FalseClass],
+ default: false
+
+ add_composer :name,
+ desc: "Name of the JWT cookie",
+ allowed: String,
+ default: "ct_jwt"
+
+ add_composer :same_site,
+ desc: "SameSite attribute for the cookie (:lax, :strict, or :none)",
+ allowed: Symbol,
+ default: :lax
+
+ add_composer :secure,
+ desc: "Secure flag for the cookie (HTTPS only). Defaults to false in development, true in production",
+ allowed: [TrueClass, FalseClass],
+ default: false
+
+ add_composer :httponly,
+ desc: "HttpOnly flag for the cookie (prevents JavaScript access)",
+ allowed: [TrueClass, FalseClass],
+ default: true
+
+ add_composer :path,
+ desc: "Path for the cookie",
+ allowed: String,
+ default: "/"
+
+ add_composer :domain,
+ desc: "Domain for the cookie (nil means host-only)",
+ allowed: [String, NilClass],
+ default: nil
+
+ add_composer :ttl,
+ desc: "Time to live for the cookie (defaults to JWT TTL)",
+ allowed: ActiveSupport::Duration,
+ default: 7.days
+
+ add_composer_blocking :csrf,
+ desc: "Double-submit CSRF protection configuration for cookie-authenticated requests",
+ composer_class: Csrf::Config,
+ enable_attr: :enabled
+
+ def enabled?
+ enabled
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/configuration/jwt/cookie/csrf/config.rb b/lib/command_tower/configuration/jwt/cookie/csrf/config.rb
new file mode 100644
index 0000000..1f82d79
--- /dev/null
+++ b/lib/command_tower/configuration/jwt/cookie/csrf/config.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Configuration
+ module Jwt
+ module Cookie
+ module Csrf
+ class Config < ::CommandTower::Configuration::Base
+ include ClassComposer::Generator
+
+ add_composer :enabled,
+ desc: "Enable double-submit CSRF protection for cookie-authenticated requests",
+ allowed: [TrueClass, FalseClass],
+ default: false
+
+ add_composer :cookie_name,
+ desc: "Name of the CSRF token cookie (must NOT be HttpOnly)",
+ allowed: String,
+ default: "ct_csrf"
+
+ add_composer :header_name,
+ desc: "Name of the CSRF token header",
+ allowed: String,
+ default: "X-CSRF-Token"
+
+ add_composer :rotate_on_login,
+ desc: "Rotate CSRF token on login",
+ allowed: [TrueClass, FalseClass],
+ default: true
+
+ add_composer :rotate_on_reset,
+ desc: "Rotate CSRF token when JWT token is reset",
+ allowed: [TrueClass, FalseClass],
+ default: true
+
+ # Note: rotate_on_logout removed - logout always clears CSRF cookie (not configurable)
+
+ # Cookie attributes (default to nil to inherit from JWT cookie settings)
+ add_composer :same_site,
+ desc: "SameSite attribute for CSRF cookie (nil inherits from JWT cookie)",
+ allowed: [Symbol, NilClass],
+ default: nil
+
+ add_composer :secure,
+ desc: "Secure flag for CSRF cookie (nil inherits from JWT cookie)",
+ allowed: [TrueClass, FalseClass, NilClass],
+ default: nil
+
+ add_composer :path,
+ desc: "Path for the CSRF cookie (nil inherits from JWT cookie)",
+ allowed: [String, NilClass],
+ default: nil
+
+ add_composer :domain,
+ desc: "Domain for the CSRF cookie (nil inherits from JWT cookie)",
+ allowed: [String, NilClass],
+ default: nil
+
+ add_composer :ttl,
+ desc: "Time to live for the CSRF cookie",
+ allowed: ActiveSupport::Duration,
+ default: 7.days
+
+ def enabled?
+ enabled
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/configuration/login/strategy/plain_text/config.rb b/lib/command_tower/configuration/login/strategy/plain_text/config.rb
index 07c6c6a..b5409a1 100644
--- a/lib/command_tower/configuration/login/strategy/plain_text/config.rb
+++ b/lib/command_tower/configuration/login/strategy/plain_text/config.rb
@@ -2,6 +2,7 @@
require "command_tower/configuration/login/strategy/plain_text/lockable"
require "command_tower/configuration/login/strategy/plain_text/email_verify"
+require "command_tower/configuration/login/strategy/plain_text/password_reset"
module CommandTower
module Configuration
@@ -26,6 +27,11 @@ class Config < ::CommandTower::Configuration::Base
composer_class: EmailVerify,
enable_attr: :enable
+ add_composer_blocking :password_reset,
+ desc: "Enable and change Password Reset for User/Password Login strategy.",
+ composer_class: PasswordReset,
+ enable_attr: :enabled
+
add_composer :password_length_max,
desc: "Max Length for Password",
allowed: Integer,
diff --git a/lib/command_tower/configuration/login/strategy/plain_text/email_verify.rb b/lib/command_tower/configuration/login/strategy/plain_text/email_verify.rb
index 003eed5..161edbc 100644
--- a/lib/command_tower/configuration/login/strategy/plain_text/email_verify.rb
+++ b/lib/command_tower/configuration/login/strategy/plain_text/email_verify.rb
@@ -42,6 +42,11 @@ class EmailVerify < ::CommandTower::Configuration::Base
default: 6,
validator: -> (val) { (val <= 10) && (val >= 4) },
invalid_message: ->(val) { "Provided #{val}. Value must be less than or equal to 10 and greater than or equal to 4." }
+
+ add_composer :custom_template_name,
+ desc: "Custom template name to use for the email verification email. Allows using a different view template without creating a custom mailer class. The template should be located at app/views/command_tower/email_verification_mailer/{template_name}.html.erb. Defaults to 'verify_email'",
+ allowed: [String, NilClass],
+ default: nil
end
end
end
diff --git a/lib/command_tower/configuration/login/strategy/plain_text/password_reset.rb b/lib/command_tower/configuration/login/strategy/plain_text/password_reset.rb
new file mode 100644
index 0000000..659d2a0
--- /dev/null
+++ b/lib/command_tower/configuration/login/strategy/plain_text/password_reset.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Configuration
+ module Login
+ module Strategy
+ module PlainText
+ class PasswordReset < ::CommandTower::Configuration::Base
+ include ClassComposer::Generator
+
+ add_composer :enabled,
+ desc: "Password reset feature allows users to request password reset via email. By default this is enabled",
+ allowed: [FalseClass, TrueClass],
+ default: true
+
+ add_composer :token_valid_for,
+ desc: "When the password reset token is sent, how long will that token be valid for. By default, this is set to 1 hour",
+ allowed: ActiveSupport::Duration,
+ default: 10.minutes,
+ validator: -> (val) { val < 24.hours },
+ invalid_message: ->(val) { "Provided #{val}. Value must be less than #{24.hours}" }
+
+ add_composer :token_length,
+ desc: "The length of the password reset token sent via email.",
+ allowed: Integer,
+ default: 32,
+ validator: -> (val) { (val <= 64) && (val >= 16) },
+ invalid_message: ->(val) { "Provided #{val}. Value must be less than or equal to 64 and greater than or equal to 16." }
+
+ add_composer :custom_template_name,
+ desc: "Custom template name to use for the password reset email. Allows using a different view template without creating a custom mailer class. The template should be located at app/views/command_tower/password_reset_mailer/{template_name}.html.erb. Defaults to 'reset_password'",
+ allowed: [String, NilClass],
+ default: nil
+
+ add_composer :require_email,
+ desc: "When enabled, requires users to provide both token and email when validating or resetting password. This adds an additional security layer to prevent brute force attacks. Defaults to false for backward compatibility.",
+ allowed: [FalseClass, TrueClass],
+ default: false
+
+ add_composer :reset_password_path,
+ desc: "The path (not full URL) to the frontend reset password page. This path will be appended to CommandTower.config.app.composed_url to form the full URL. Used in email template for the reset link. Defaults to '/reset-password'",
+ allowed: String,
+ default: "/reset-password"
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/configuration/user/config.rb b/lib/command_tower/configuration/user/config.rb
index 9a87df1..a672f66 100644
--- a/lib/command_tower/configuration/user/config.rb
+++ b/lib/command_tower/configuration/user/config.rb
@@ -12,16 +12,17 @@ class Config
:email,
:first_name,
:last_name,
- :last_known_timezone,
:username,
:verifier_token,
]
ATTRIBUTES_TO_SHOW = [
- *ATTRIBUTES_TO_CHANGE,
+ *(ATTRIBUTES_TO_CHANGE - [:verifier_token]),
+ :created_at,
+ :email_validated,
:id,
+ :last_known_timezone,
:roles,
- :created_at,
]
ATTRIBUTES_CHANGE_EXECUTE = Proc.new do |key, value|
@@ -29,7 +30,7 @@ class Config
end
ATTRIBUTES_SHOWN_EXECUTE = Proc.new do |key, value|
- CommandTower::Schema::User.assign!
+ CommandTower::Schema::Shared::User.assign!
end
add_composer :additional_attributes_for_change,
diff --git a/lib/command_tower/engine.rb b/lib/command_tower/engine.rb
index 28431a4..a00409a 100644
--- a/lib/command_tower/engine.rb
+++ b/lib/command_tower/engine.rb
@@ -10,7 +10,7 @@ class Engine < ::Rails::Engine
# Run after Rails loads the initializes and environment files
# Ensures User has already set their desired config before we lock this down
config.after_initialize do
- db_rake_task = defined?(Rake) && (Rake.application.top_level_tasks.any? { |task| task =~ /db:/ } rescue nil)
+ db_rake_task = defined?(Rake) && (Rake.application.top_level_tasks.any? { |task| task =~ /db:|install:migrations/ } rescue nil)
if db_rake_task
# Because we call the Database during configuration setup,
# We want to skip calling the DB during a DB migration
diff --git a/lib/command_tower/jwt/authorization_helper.rb b/lib/command_tower/jwt/authorization_helper.rb
new file mode 100644
index 0000000..fdb3617
--- /dev/null
+++ b/lib/command_tower/jwt/authorization_helper.rb
@@ -0,0 +1,136 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Jwt
+ module AuthorizationHelper
+ AUTHENTICATION_HEADER = "Authorization"
+ AUTHENTICATION_EXPIRE_HEADER = "X-Authorization-Expire"
+ AUTHENTICATION_WITH_RESET = "X-Authorization-Reset"
+
+ module_function
+
+ # Validates and extracts token from Authorization header OR cookie (if enabled)
+ # Returns hash with :token and :source on success, or hash with :error and :message on failure
+ # @param request [ActionDispatch::Request] The request object
+ # @return [Hash] Hash with :token and :source (:header, :cookie) on success, or :error and :message on failure
+ def extract_token(request)
+ # First try Authorization header
+ raw_token = request.headers[AUTHENTICATION_HEADER]
+ if raw_token && !raw_token.to_s.strip.empty?
+ # Validate Bearer format with strict regex (case-insensitive)
+ # Matches: \ABearer\s+(.+)\z
+ # - \A: start of string
+ # - Bearer: literal "Bearer" (case-insensitive with /i flag)
+ # - \s+: one or more whitespace characters
+ # - (.+): capture group for token (one or more characters)
+ # - \z: end of string
+ match = raw_token.match(/\ABearer\s+(.+)\z/i)
+ if match && match[1] && !match[1].strip.empty?
+ token = match[1].strip
+ return { token:, source: :header }
+ else
+ return { error: :invalid_format, message: "Invalid Bearer token format" }
+ end
+ end
+
+ # Fallback to cookie if header is missing/empty AND cookie enabled
+ if cookie_enabled?
+ cookie_token = read_cookie(request)
+ return { token: cookie_token, source: :cookie } if cookie_token && !cookie_token.strip.empty?
+ end
+
+ # No token found
+ { error: :missing, message: "Bearer token missing" }
+ end
+
+ # Sets token in response (headers and optionally cookie)
+ # @param response [ActionDispatch::Response] The response object
+ # @param token [String] The JWT token to set
+ # @param expires_at [String, nil] Optional expiration time string
+ def set_token(response, token, expires_at: nil)
+ # Always set headers
+ response.set_header(AUTHENTICATION_WITH_RESET, token)
+ response.set_header(AUTHENTICATION_EXPIRE_HEADER, expires_at) if expires_at && !expires_at.to_s.strip.empty?
+
+ # Set cookie if enabled
+ set_cookie(response, token) if cookie_enabled?
+ end
+
+ # Clears token from response (cookie only, headers are response-only)
+ # @param response [ActionDispatch::Response] The response object
+ def clear_token(response)
+ clear_cookie(response) if cookie_enabled?
+ # Clear CSRF cookie on logout
+ # Logout always clears CSRF cookie (not configurable - this is the only correct behavior)
+ CommandTower::Jwt::CsrfHelper.clear_cookie(response) if CommandTower::Jwt::CsrfHelper.csrf_enabled?
+ end
+
+ # Reads cookie value from request
+ # @param request [ActionDispatch::Request] The request object
+ # @return [String, nil] Cookie value or nil
+ def read_cookie(request)
+ return nil unless cookie_enabled?
+
+ request.cookies[CommandTower.config.jwt.cookie.name.downcase]
+ end
+
+ # Returns consistent cookie options hash (single source of truth)
+ # @param expires_at [Time, ActiveSupport::TimeWithZone] Expiration time for the cookie
+ # @return [Hash] Cookie options hash with all attributes
+ def cookie_options(expires_at:)
+ config = CommandTower.config.jwt.cookie
+ secure_value = config.secure || Rails.env.production?
+ options = {
+ expires: expires_at,
+ httponly: config.httponly,
+ secure: secure_value,
+ same_site: config.same_site,
+ path: config.path
+ }
+ options[:domain] = config.domain if config.domain.present?
+ options
+ end
+
+ # Sets cookie with configured options
+ # @param response [ActionDispatch::Response] The response object
+ # @param token [String] The JWT token to set
+ def set_cookie(response, token)
+ return unless cookie_enabled?
+
+ config = CommandTower.config.jwt.cookie
+ options = cookie_options(expires_at: config.ttl.from_now)
+ options[:value] = token
+
+ response.set_cookie(config.name, options)
+ end
+
+ # Clears cookie by setting it to expire in the past
+ # @param response [ActionDispatch::Response] The response object
+ def clear_cookie(response)
+ return unless cookie_enabled?
+
+ config = CommandTower.config.jwt.cookie
+ options = cookie_options(expires_at: 1.year.ago)
+ options[:value] = ""
+
+ response.set_cookie(config.name, options)
+ end
+
+ # Gets the with_reset flag from request headers
+ # @param request [ActionDispatch::Request] The request object
+ # @return [Boolean, nil] The boolean value or nil
+ def get_with_reset_flag(request)
+ value = request.headers[AUTHENTICATION_WITH_RESET]
+ return nil unless [true, false, "true", "false", "0", "1", 0, 1].include?(value)
+
+ ActiveModel::Type::Boolean.new.cast(value)
+ end
+
+ # Checks if cookie auth is enabled
+ # @return [Boolean]
+ def cookie_enabled?
+ CommandTower.config.jwt.cookie.enabled?
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/jwt/csrf_helper.rb b/lib/command_tower/jwt/csrf_helper.rb
new file mode 100644
index 0000000..651ec54
--- /dev/null
+++ b/lib/command_tower/jwt/csrf_helper.rb
@@ -0,0 +1,138 @@
+# frozen_string_literal: true
+
+require "active_support/security_utils"
+
+module CommandTower
+ module Jwt
+ module CsrfHelper
+ module_function
+
+ # Generate a new CSRF token
+ def generate_token
+ SecureRandom.hex(32)
+ end
+
+ # Set CSRF cookie in response
+ def set_cookie(response, token)
+ return unless csrf_enabled?
+
+ config = CommandTower.config.jwt.cookie.csrf
+ options = csrf_cookie_options(expires_at: config.ttl.from_now)
+ options[:value] = token
+
+ response.set_cookie(config.cookie_name, options)
+ end
+
+ # Clear CSRF cookie
+ def clear_cookie(response)
+ return unless csrf_enabled?
+
+ config = CommandTower.config.jwt.cookie.csrf
+ options = csrf_cookie_options(expires_at: 1.year.ago)
+ options[:value] = ""
+ options[:max_age] = 0
+
+ response.set_cookie(config.cookie_name, options)
+ end
+
+ # Ensure CSRF cookie exists (rotate-or-ensure model)
+ # - If should_rotate is true → generate + set new CSRF cookie
+ # - Else if existing cookie is blank → generate + set new CSRF cookie
+ # - Else do nothing (cookie already exists)
+ def ensure_cookie(request, response, should_rotate: false)
+ return unless csrf_enabled?
+
+ # Read cookie once into local variable
+ existing_cookie = read_cookie(request)
+ existing_cookie_blank = existing_cookie.nil? || existing_cookie.strip.empty?
+
+ if should_rotate || existing_cookie_blank
+ # Generate and set new CSRF cookie
+ token = generate_token
+ set_cookie(response, token)
+ end
+ # Else: cookie exists and we're not rotating, do nothing
+ end
+
+ # Read CSRF token from cookie
+ def read_cookie(request)
+ return nil unless csrf_enabled?
+
+ config = CommandTower.config.jwt.cookie.csrf
+ request.cookies[config.cookie_name]
+ end
+
+ # Read CSRF token from header
+ def read_header(request)
+ return nil unless csrf_enabled?
+
+ config = CommandTower.config.jwt.cookie.csrf
+ request.headers[config.header_name]
+ end
+
+ # Validate CSRF token (compare cookie to header)
+ # Uses constant-time comparison for security hardening
+ def validate(request)
+ return { valid: true } unless csrf_enabled?
+
+ # read_cookie and read_header also check csrf_enabled?, but that's fine for redundancy
+ cookie_token = read_cookie(request)
+ header_token = read_header(request)
+
+ if cookie_token.nil? || cookie_token.strip.empty?
+ return { valid: false, error: :csrf_missing, message: "csrf_missing" }
+ end
+
+ if header_token.nil? || header_token.strip.empty?
+ return { valid: false, error: :csrf_mismatch, message: "csrf_missing" }
+ end
+
+ # Normalize tokens
+ cookie_normalized = cookie_token.to_s.strip
+ header_normalized = header_token.to_s.strip
+
+ # Use constant-time comparison to prevent timing attacks
+ if cookie_normalized.length != header_normalized.length
+ return { valid: false, error: :csrf_mismatch, message: "csrf_mismatch" }
+ end
+
+ unless ActiveSupport::SecurityUtils.secure_compare(cookie_normalized, header_normalized)
+ return { valid: false, error: :csrf_mismatch, message: "csrf_mismatch" }
+ end
+
+ { valid: true }
+ end
+
+ # Check if CSRF is enabled
+ # CSRF can only be enabled when cookie auth is also enabled
+ def csrf_enabled?
+ CommandTower::Jwt::AuthorizationHelper.cookie_enabled? && CommandTower.config.jwt.cookie.csrf.enabled?
+ end
+
+ # Get cookie options (single source of truth)
+ # Uses explicit nil checks (not ||) for true nil-coalescing of inheritable fields
+ def csrf_cookie_options(expires_at:)
+ config = CommandTower.config.jwt.cookie.csrf
+ jwt_config = CommandTower.config.jwt.cookie
+
+ # Boolean handling for secure (already fixed)
+ secure_value = config.secure.nil? ? (jwt_config.secure.nil? ? Rails.env.production? : jwt_config.secure) : config.secure
+
+ # Explicit nil checks for same_site, path, domain (not || to respect explicit false/nil)
+ same_site_value = config.same_site.nil? ? jwt_config.same_site : config.same_site
+ path_value = config.path.nil? ? jwt_config.path : config.path
+ domain_value = config.domain.nil? ? jwt_config.domain : config.domain
+
+ options = {
+ expires: expires_at,
+ httponly: false, # CRITICAL: Must be readable by JavaScript (defined once)
+ secure: secure_value,
+ same_site: same_site_value,
+ path: path_value
+ }
+ options[:domain] = domain_value if domain_value.present?
+ options
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema.rb b/lib/command_tower/schema.rb
index 786d6ea..530624f 100644
--- a/lib/command_tower/schema.rb
+++ b/lib/command_tower/schema.rb
@@ -7,32 +7,74 @@ module Schema
## Generic Error Schemas
require "command_tower/schema/error/base"
+ require "command_tower/schema/error/email_validation_required"
require "command_tower/schema/error/invalid_argument_response"
- ## Plain Text Controller
- require "command_tower/schema/plain_text/create_user_response"
- require "command_tower/schema/plain_text/create_user_request"
+ ## Auth Controller
+ require "command_tower/schema/auth/plain_text/login/request"
+ require "command_tower/schema/auth/plain_text/login/response"
+ require "command_tower/schema/auth/plain_text/login_identifier_valid/request"
+ require "command_tower/schema/auth/plain_text/login_identifier_valid/response"
+ require "command_tower/schema/auth/plain_text/create_user/request"
+ require "command_tower/schema/auth/plain_text/create_user/response"
+ require "command_tower/schema/auth/plain_text/email_verify/request"
+ require "command_tower/schema/auth/plain_text/email_verify/response"
+ require "command_tower/schema/auth/plain_text/email_verify/send_request"
+ require "command_tower/schema/auth/plain_text/email_verify/send_response"
+ require "command_tower/schema/auth/plain_text/password_forgot"
+ require "command_tower/schema/auth/plain_text/password_forgot/send/request"
+ require "command_tower/schema/auth/plain_text/password_forgot/send/response"
+ require "command_tower/schema/auth/plain_text/password_forgot/validate/request"
+ require "command_tower/schema/auth/plain_text/password_forgot/validate/response"
+ require "command_tower/schema/auth/plain_text/password_forgot/reset/request"
+ require "command_tower/schema/auth/plain_text/password_forgot/reset/response"
+ require "command_tower/schema/auth/plain_text/change_password"
+ require "command_tower/schema/auth/plain_text/change_password/request"
+ require "command_tower/schema/auth/plain_text/change_password/response"
+ require "command_tower/schema/auth/logout/response"
- require "command_tower/schema/plain_text/email_verify_request"
- require "command_tower/schema/plain_text/email_verify_response"
+ require "command_tower/schema/shared/admin/users"
- require "command_tower/schema/plain_text/email_verify_send_response"
- require "command_tower/schema/plain_text/email_verify_send_request"
+ require "command_tower/schema/shared/user"
- require "command_tower/schema/plain_text/login_request"
- require "command_tower/schema/plain_text/login_response"
+ require "command_tower/schema/user/show/request"
+ require "command_tower/schema/user/show/response"
+ require "command_tower/schema/user/modify/request"
+ require "command_tower/schema/user/modify/response"
- require "command_tower/schema/admin/users"
+ require "command_tower/schema/shared/page"
+ require "command_tower/schema/shared/pagination"
- require "command_tower/schema/user"
- require "command_tower/schema/page"
+ require "command_tower/schema/shared/inbox/metadata"
+ require "command_tower/schema/shared/inbox/message_blast_metadata"
+ require "command_tower/schema/shared/inbox/modified"
- require "command_tower/schema/inbox/metadata"
- require "command_tower/schema/inbox/message_entity"
- require "command_tower/schema/inbox/modified"
- require "command_tower/schema/inbox/blast_response"
- require "command_tower/schema/inbox/blast_request"
- require "command_tower/schema/inbox/message_blast_entity"
- require "command_tower/schema/inbox/message_blast_metadata"
+ require "command_tower/schema/admin/show/request"
+ require "command_tower/schema/admin/show/response"
+ require "command_tower/schema/admin/modify/request"
+ require "command_tower/schema/admin/modify/response"
+ require "command_tower/schema/admin/modify_role/request"
+ require "command_tower/schema/admin/modify_role/response"
+
+ require "command_tower/schema/inbox/messages/metadata/request"
+ require "command_tower/schema/inbox/messages/metadata/response"
+ require "command_tower/schema/inbox/messages/message/request"
+ require "command_tower/schema/inbox/messages/message/response"
+ require "command_tower/schema/inbox/messages/ack/request"
+ require "command_tower/schema/inbox/messages/ack/response"
+ require "command_tower/schema/inbox/messages/delete/request"
+ require "command_tower/schema/inbox/messages/delete/response"
+ require "command_tower/schema/inbox/messages/delete_by_id/request"
+ require "command_tower/schema/inbox/messages/delete_by_id/response"
+ require "command_tower/schema/inbox/blast/metadata/request"
+ require "command_tower/schema/inbox/blast/metadata/response"
+ require "command_tower/schema/inbox/blast/create/request"
+ require "command_tower/schema/inbox/blast/create/response"
+ require "command_tower/schema/inbox/blast/show/request"
+ require "command_tower/schema/inbox/blast/show/response"
+ require "command_tower/schema/inbox/blast/modify/request"
+ require "command_tower/schema/inbox/blast/modify/response"
+ require "command_tower/schema/inbox/blast/delete/request"
+ require "command_tower/schema/inbox/blast/delete/response"
end
end
diff --git a/lib/command_tower/schema/admin/modify/request.rb b/lib/command_tower/schema/admin/modify/request.rb
new file mode 100644
index 0000000..76f422e
--- /dev/null
+++ b/lib/command_tower/schema/admin/modify/request.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Admin
+ module Modify
+ class Request < JsonSchematize::Generator
+ schema_default option: :dig_type, value: :string
+
+ add_field name: :user_id, type: Integer, required: true
+ add_field name: :email, type: String, required: false
+ add_field name: :email_validated, type: JsonSchematize::Boolean, required: false
+ add_field name: :first_name, type: String, required: false
+ add_field name: :last_name, type: String, required: false
+ add_field name: :username, type: String, required: false
+ add_field name: :verifier_token, type: JsonSchematize::Boolean, required: false
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/admin/modify/response.rb b/lib/command_tower/schema/admin/modify/response.rb
new file mode 100644
index 0000000..c20046d
--- /dev/null
+++ b/lib/command_tower/schema/admin/modify/response.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/shared/user"
+
+module CommandTower
+ module Schema
+ module Admin
+ module Modify
+ class Response < JsonSchematize::Generator
+ # Response is just the User schema
+ # We'll use Shared::User directly in the controller
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/admin/modify_role/request.rb b/lib/command_tower/schema/admin/modify_role/request.rb
new file mode 100644
index 0000000..c38fe9d
--- /dev/null
+++ b/lib/command_tower/schema/admin/modify_role/request.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Admin
+ module ModifyRole
+ class Request < JsonSchematize::Generator
+ schema_default option: :dig_type, value: :string
+
+ add_field name: :user_id, type: Integer, required: true
+ add_field name: :roles, type: Array, required: false
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/admin/modify_role/response.rb b/lib/command_tower/schema/admin/modify_role/response.rb
new file mode 100644
index 0000000..54910b8
--- /dev/null
+++ b/lib/command_tower/schema/admin/modify_role/response.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/shared/user"
+
+module CommandTower
+ module Schema
+ module Admin
+ module ModifyRole
+ class Response < JsonSchematize::Generator
+ # Response is just the User schema
+ # We'll use Shared::User directly in the controller
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/admin/show/request.rb b/lib/command_tower/schema/admin/show/request.rb
new file mode 100644
index 0000000..e378fcd
--- /dev/null
+++ b/lib/command_tower/schema/admin/show/request.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Admin
+ module Show
+ class Request < JsonSchematize::Generator
+ # GET endpoint - no request body validation needed
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/admin/show/response.rb b/lib/command_tower/schema/admin/show/response.rb
new file mode 100644
index 0000000..e80043d
--- /dev/null
+++ b/lib/command_tower/schema/admin/show/response.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/shared/admin/users"
+
+module CommandTower
+ module Schema
+ module Admin
+ module Show
+ class Response < JsonSchematize::Generator
+ # Response is the Shared::Admin::Users schema
+ # We'll use it directly in the controller
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/admin/users.rb b/lib/command_tower/schema/admin/users.rb
deleted file mode 100644
index 9e9cbfd..0000000
--- a/lib/command_tower/schema/admin/users.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-require "command_tower/schema/user"
-require "command_tower/schema/pagination"
-
-module CommandTower
- module Schema
- module Admin
- class Users < JsonSchematize::Generator
- add_field name: :users, array_of_types: true, type: CommandTower::Schema::User
- add_field name: :count, type: Integer, required: false
- add_field name: :pagination, type: CommandTower::Schema::Pagination, required: false
- end
- end
- end
-end
diff --git a/lib/command_tower/schema/auth/logout/response.rb b/lib/command_tower/schema/auth/logout/response.rb
new file mode 100644
index 0000000..860a82f
--- /dev/null
+++ b/lib/command_tower/schema/auth/logout/response.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Auth
+ module Logout
+ class Response < JsonSchematize::Generator
+ add_field name: :message, type: String
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/change_password.rb b/lib/command_tower/schema/auth/plain_text/change_password.rb
new file mode 100644
index 0000000..ca9ab56
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/change_password.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module ChangePassword
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/change_password/request.rb b/lib/command_tower/schema/auth/plain_text/change_password/request.rb
new file mode 100644
index 0000000..ce249ea
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/change_password/request.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module ChangePassword
+ class Request < JsonSchematize::Generator
+ schema_default option: :dig_type, value: :string
+
+ add_field name: :current_password, type: String, required: true
+ add_field name: :password, type: String, required: true
+ add_field name: :password_confirmation, type: String, required: true
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/change_password/response.rb b/lib/command_tower/schema/auth/plain_text/change_password/response.rb
new file mode 100644
index 0000000..8b2f0a9
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/change_password/response.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module ChangePassword
+ class Response < JsonSchematize::Generator
+ add_field name: :message, type: String
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/create_user/request.rb b/lib/command_tower/schema/auth/plain_text/create_user/request.rb
new file mode 100644
index 0000000..362b2a7
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/create_user/request.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module CreateUser
+ class Request < JsonSchematize::Generator
+ schema_default option: :dig_type, value: :string
+
+ add_field name: :first_name, type: String, required: false
+ add_field name: :last_name, type: String, required: false
+ add_field name: :username, type: String, required: false
+ add_field name: :email, type: String, required: false
+ add_field name: :password, type: String, required: false
+ add_field name: :password_confirmation, type: String, required: false
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/create_user/response.rb b/lib/command_tower/schema/auth/plain_text/create_user/response.rb
new file mode 100644
index 0000000..63f2c0e
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/create_user/response.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module CreateUser
+ class Response < JsonSchematize::Generator
+ add_field name: :full_name, type: String
+ add_field name: :first_name, type: String
+ add_field name: :last_name, type: String
+ add_field name: :username, type: String
+ add_field name: :email, type: String
+ add_field name: :msg, type: String
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/email_verify/request.rb b/lib/command_tower/schema/auth/plain_text/email_verify/request.rb
new file mode 100644
index 0000000..7e94325
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/email_verify/request.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module EmailVerify
+ class Request < JsonSchematize::Generator
+ add_field name: :code, type: String
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/email_verify/response.rb b/lib/command_tower/schema/auth/plain_text/email_verify/response.rb
new file mode 100644
index 0000000..ac640b0
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/email_verify/response.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module EmailVerify
+ class Response < JsonSchematize::Generator
+ add_field name: :message, type: String
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/email_verify/send_request.rb b/lib/command_tower/schema/auth/plain_text/email_verify/send_request.rb
new file mode 100644
index 0000000..3c2cfa1
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/email_verify/send_request.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module EmailVerify
+ class SendRequest < JsonSchematize::Generator; end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/email_verify/send_response.rb b/lib/command_tower/schema/auth/plain_text/email_verify/send_response.rb
new file mode 100644
index 0000000..796446e
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/email_verify/send_response.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module EmailVerify
+ class SendResponse < JsonSchematize::Generator
+ add_field name: :message, type: String
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/login/request.rb b/lib/command_tower/schema/auth/plain_text/login/request.rb
new file mode 100644
index 0000000..d431a64
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/login/request.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module Login
+ class Request < JsonSchematize::Generator
+ schema_default option: :dig_type, value: :string
+
+ add_field name: :identifier, type: String, required: false
+ add_field name: :password, type: String, required: false
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/login/response.rb b/lib/command_tower/schema/auth/plain_text/login/response.rb
new file mode 100644
index 0000000..f073d28
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/login/response.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/shared/user"
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module Login
+ class Response < JsonSchematize::Generator
+ add_field name: :token, type: String
+ add_field name: :header_name, type: String
+ add_field name: :message, type: String
+ add_field name: :user, type: CommandTower::Schema::Shared::User
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/login_identifier_valid/request.rb b/lib/command_tower/schema/auth/plain_text/login_identifier_valid/request.rb
new file mode 100644
index 0000000..f56e235
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/login_identifier_valid/request.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module LoginIdentifierValid
+ class Request < JsonSchematize::Generator
+ schema_default option: :dig_type, value: :string
+
+ add_field name: :identifier, type: String, required: false
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/login_identifier_valid/response.rb b/lib/command_tower/schema/auth/plain_text/login_identifier_valid/response.rb
new file mode 100644
index 0000000..56d07ec
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/login_identifier_valid/response.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/shared/user"
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module LoginIdentifierValid
+ class Response < JsonSchematize::Generator
+ add_field name: :valid, type: JsonSchematize::Boolean
+ add_field name: :message, type: String
+ add_field name: :user, type: CommandTower::Schema::Shared::User
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/password_forgot.rb b/lib/command_tower/schema/auth/plain_text/password_forgot.rb
new file mode 100644
index 0000000..145ef41
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/password_forgot.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module PasswordForgot
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/password_forgot/reset/request.rb b/lib/command_tower/schema/auth/plain_text/password_forgot/reset/request.rb
new file mode 100644
index 0000000..04d1e08
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/password_forgot/reset/request.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module PasswordForgot
+ module Reset
+ class Request < JsonSchematize::Generator
+ schema_default option: :dig_type, value: :string
+
+ add_field name: :token, type: String, required: true
+ add_field name: :email, type: String, required: false
+ add_field name: :password, type: String, required: true
+ add_field name: :password_confirmation, type: String, required: true
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/password_forgot/reset/response.rb b/lib/command_tower/schema/auth/plain_text/password_forgot/reset/response.rb
new file mode 100644
index 0000000..75dd535
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/password_forgot/reset/response.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module PasswordForgot
+ module Reset
+ class Response < JsonSchematize::Generator
+ add_field name: :message, type: String
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/password_forgot/send/request.rb b/lib/command_tower/schema/auth/plain_text/password_forgot/send/request.rb
new file mode 100644
index 0000000..361d1aa
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/password_forgot/send/request.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module PasswordForgot
+ module Send
+ class Request < JsonSchematize::Generator
+ schema_default option: :dig_type, value: :string
+
+ add_field name: :email, type: String, required: true
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/password_forgot/send/response.rb b/lib/command_tower/schema/auth/plain_text/password_forgot/send/response.rb
new file mode 100644
index 0000000..6ec52e1
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/password_forgot/send/response.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module PasswordForgot
+ module Send
+ class Response < JsonSchematize::Generator
+ add_field name: :message, type: String
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/password_forgot/validate/request.rb b/lib/command_tower/schema/auth/plain_text/password_forgot/validate/request.rb
new file mode 100644
index 0000000..08d9a4e
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/password_forgot/validate/request.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module PasswordForgot
+ module Validate
+ class Request < JsonSchematize::Generator
+ schema_default option: :dig_type, value: :string
+
+ add_field name: :token, type: String, required: true
+ add_field name: :email, type: String, required: false
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/auth/plain_text/password_forgot/validate/response.rb b/lib/command_tower/schema/auth/plain_text/password_forgot/validate/response.rb
new file mode 100644
index 0000000..08560a9
--- /dev/null
+++ b/lib/command_tower/schema/auth/plain_text/password_forgot/validate/response.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Auth
+ module PlainText
+ module PasswordForgot
+ module Validate
+ class Response < JsonSchematize::Generator
+ add_field name: :valid, type: JsonSchematize::Boolean
+ add_field name: :expires_at, type: String, required: false
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/entities/inbox/message_blast_entity.rb b/lib/command_tower/schema/entities/inbox/message_blast_entity.rb
new file mode 100644
index 0000000..63ee8db
--- /dev/null
+++ b/lib/command_tower/schema/entities/inbox/message_blast_entity.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/shared/user"
+
+module CommandTower
+ module Schema
+ module Entities
+ module Inbox
+ class MessageBlastEntity < JsonSchematize::Generator
+ add_field name: :created_by, type: CommandTower::Schema::Shared::User, required: false # to allow metadata call to be fast
+ add_field name: :text, type: String, required: false # to allow metadata call to be fast
+ add_field name: :title, type: String
+ add_field name: :id, type: Integer
+ add_field name: :existing_users, type: JsonSchematize::Boolean
+ add_field name: :new_users, type: JsonSchematize::Boolean
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/entities/inbox/message_entity.rb b/lib/command_tower/schema/entities/inbox/message_entity.rb
new file mode 100644
index 0000000..ee5b60b
--- /dev/null
+++ b/lib/command_tower/schema/entities/inbox/message_entity.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Entities
+ module Inbox
+ class MessageEntity < JsonSchematize::Generator
+ add_field name: :title, type: String
+ add_field name: :id, type: Integer
+ add_field name: :text, type: String, required: false
+ add_field name: :viewed, type: JsonSchematize::Boolean
+ add_field name: :created_at, type: String
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/error/email_validation_required.rb b/lib/command_tower/schema/error/email_validation_required.rb
new file mode 100644
index 0000000..44edfd7
--- /dev/null
+++ b/lib/command_tower/schema/error/email_validation_required.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require "json_schematize/generator"
+
+module CommandTower
+ module Schema
+ module Error
+ class EmailValidationRequired < JsonSchematize::Generator
+ add_field name: :status, type: String
+ add_field name: :message, type: String
+ add_field name: :meta, type: Hash
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/blast/create/request.rb b/lib/command_tower/schema/inbox/blast/create/request.rb
new file mode 100644
index 0000000..cd0303f
--- /dev/null
+++ b/lib/command_tower/schema/inbox/blast/create/request.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Blast
+ module Create
+ class Request < JsonSchematize::Generator
+ add_field name: :existing_users, type: JsonSchematize::Boolean
+ add_field name: :new_users, type: JsonSchematize::Boolean
+ add_field name: :title, type: String
+ add_field name: :text, type: String
+ add_field name: :id, type: Integer, required: false
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/blast/create/response.rb b/lib/command_tower/schema/inbox/blast/create/response.rb
new file mode 100644
index 0000000..d08e9f2
--- /dev/null
+++ b/lib/command_tower/schema/inbox/blast/create/response.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/shared/user"
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Blast
+ module Create
+ class Response < JsonSchematize::Generator
+ add_field name: :created_by, type: CommandTower::Schema::Shared::User
+ add_field name: :existing_users, type: JsonSchematize::Boolean
+ add_field name: :new_users, type: JsonSchematize::Boolean
+ add_field name: :title, type: String
+ add_field name: :text, type: String
+ add_field name: :id, type: Integer, required: false
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/blast/delete/request.rb b/lib/command_tower/schema/inbox/blast/delete/request.rb
new file mode 100644
index 0000000..5638fde
--- /dev/null
+++ b/lib/command_tower/schema/inbox/blast/delete/request.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Blast
+ module Delete
+ class Request < JsonSchematize::Generator
+ schema_default option: :dig_type, value: :string
+
+ add_field name: :id, type: Integer, required: false
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/blast/delete/response.rb b/lib/command_tower/schema/inbox/blast/delete/response.rb
new file mode 100644
index 0000000..60bbea0
--- /dev/null
+++ b/lib/command_tower/schema/inbox/blast/delete/response.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Blast
+ module Delete
+ class Response < JsonSchematize::Generator
+ add_field name: :id, type: Integer
+ add_field name: :msg, type: String
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/blast/metadata/request.rb b/lib/command_tower/schema/inbox/blast/metadata/request.rb
new file mode 100644
index 0000000..5974119
--- /dev/null
+++ b/lib/command_tower/schema/inbox/blast/metadata/request.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Blast
+ module Metadata
+ class Request < JsonSchematize::Generator
+ # GET endpoint - pagination handled separately
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/blast/metadata/response.rb b/lib/command_tower/schema/inbox/blast/metadata/response.rb
new file mode 100644
index 0000000..3065efb
--- /dev/null
+++ b/lib/command_tower/schema/inbox/blast/metadata/response.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/shared/inbox/message_blast_metadata"
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Blast
+ module Metadata
+ class Response < JsonSchematize::Generator
+ # Response is the Shared::Inbox::MessageBlastMetadata schema
+ # We'll use it directly in the controller
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/blast/modify/request.rb b/lib/command_tower/schema/inbox/blast/modify/request.rb
new file mode 100644
index 0000000..d2e7467
--- /dev/null
+++ b/lib/command_tower/schema/inbox/blast/modify/request.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Blast
+ module Modify
+ class Request < JsonSchematize::Generator
+ add_field name: :existing_users, type: JsonSchematize::Boolean
+ add_field name: :new_users, type: JsonSchematize::Boolean
+ add_field name: :title, type: String
+ add_field name: :text, type: String
+ add_field name: :id, type: Integer, required: false
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/blast/modify/response.rb b/lib/command_tower/schema/inbox/blast/modify/response.rb
new file mode 100644
index 0000000..75ea9cf
--- /dev/null
+++ b/lib/command_tower/schema/inbox/blast/modify/response.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/shared/user"
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Blast
+ module Modify
+ class Response < JsonSchematize::Generator
+ add_field name: :created_by, type: CommandTower::Schema::Shared::User
+ add_field name: :existing_users, type: JsonSchematize::Boolean
+ add_field name: :new_users, type: JsonSchematize::Boolean
+ add_field name: :title, type: String
+ add_field name: :text, type: String
+ add_field name: :id, type: Integer, required: false
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/blast/show/request.rb b/lib/command_tower/schema/inbox/blast/show/request.rb
new file mode 100644
index 0000000..47481ae
--- /dev/null
+++ b/lib/command_tower/schema/inbox/blast/show/request.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Blast
+ module Show
+ class Request < JsonSchematize::Generator
+ schema_default option: :dig_type, value: :string
+
+ add_field name: :id, type: Integer, required: false
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/blast/show/response.rb b/lib/command_tower/schema/inbox/blast/show/response.rb
new file mode 100644
index 0000000..4a3bae8
--- /dev/null
+++ b/lib/command_tower/schema/inbox/blast/show/response.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/entities/inbox/message_blast_entity"
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Blast
+ module Show
+ class Response < JsonSchematize::Generator
+ # Response is the Entities::Inbox::MessageBlastEntity schema
+ # We'll use it directly in the controller
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/blast_request.rb b/lib/command_tower/schema/inbox/blast_request.rb
deleted file mode 100644
index bad48fb..0000000
--- a/lib/command_tower/schema/inbox/blast_request.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-module CommandTower
- module Schema
- module Inbox
- class BlastRequest < JsonSchematize::Generator
- add_field name: :existing_users, type: JsonSchematize::Boolean
- add_field name: :new_users, type: JsonSchematize::Boolean
- add_field name: :title, type: String
- add_field name: :text, type: String
- add_field name: :id, type: Integer, required: false
- end
- end
- end
-end
diff --git a/lib/command_tower/schema/inbox/blast_response.rb b/lib/command_tower/schema/inbox/blast_response.rb
deleted file mode 100644
index 932e92a..0000000
--- a/lib/command_tower/schema/inbox/blast_response.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-module CommandTower
- module Schema
- module Inbox
- class BlastResponse < JsonSchematize::Generator
- add_field name: :created_by, type: CommandTower::Schema::User
- add_field name: :existing_users, type: JsonSchematize::Boolean
- add_field name: :new_users, type: JsonSchematize::Boolean
- add_field name: :title, type: String
- add_field name: :text, type: String
- add_field name: :id, type: Integer, required: false
- end
- end
- end
-end
diff --git a/lib/command_tower/schema/inbox/message_blast_entity.rb b/lib/command_tower/schema/inbox/message_blast_entity.rb
deleted file mode 100644
index 8c54952..0000000
--- a/lib/command_tower/schema/inbox/message_blast_entity.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-module CommandTower
- module Schema
- module Inbox
- class MessageBlastEntity < JsonSchematize::Generator
- add_field name: :created_by, type: CommandTower::Schema::User, required: false # to allow metadata call to be fast
- add_field name: :text, type: String, required: false # to allow metadata call to be fast
- add_field name: :title, type: String
- add_field name: :id, type: Integer
- add_field name: :existing_users, type: JsonSchematize::Boolean
- add_field name: :new_users, type: JsonSchematize::Boolean
- end
- end
- end
-end
diff --git a/lib/command_tower/schema/inbox/message_blast_metadata.rb b/lib/command_tower/schema/inbox/message_blast_metadata.rb
deleted file mode 100644
index 2c07109..0000000
--- a/lib/command_tower/schema/inbox/message_blast_metadata.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-require "command_tower/schema/inbox/message_blast_entity"
-require "command_tower/schema/pagination"
-
-module CommandTower
- module Schema
- module Inbox
- class MessageBlastMetadata < JsonSchematize::Generator
- add_field name: :entities, array_of_types: true, type: CommandTower::Schema::Inbox::MessageBlastEntity, required: false
- add_field name: :count, type: Integer
- add_field name: :pagination, type: CommandTower::Schema::Pagination, required: false
- end
- end
- end
-end
diff --git a/lib/command_tower/schema/inbox/message_entity.rb b/lib/command_tower/schema/inbox/message_entity.rb
deleted file mode 100644
index 472bf9d..0000000
--- a/lib/command_tower/schema/inbox/message_entity.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-# frozen_string_literal: true
-
-module CommandTower
- module Schema
- module Inbox
- class MessageEntity < JsonSchematize::Generator
- add_field name: :title, type: String
- add_field name: :id, type: Integer
- add_field name: :text, type: String, required: false
- add_field name: :viewed, type: JsonSchematize::Boolean
- end
- end
- end
-end
diff --git a/lib/command_tower/schema/inbox/messages/ack/request.rb b/lib/command_tower/schema/inbox/messages/ack/request.rb
new file mode 100644
index 0000000..4b57279
--- /dev/null
+++ b/lib/command_tower/schema/inbox/messages/ack/request.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Messages
+ module Ack
+ class Request < JsonSchematize::Generator
+ add_field name: :ids, type: Array, required: true
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/messages/ack/response.rb b/lib/command_tower/schema/inbox/messages/ack/response.rb
new file mode 100644
index 0000000..2db2409
--- /dev/null
+++ b/lib/command_tower/schema/inbox/messages/ack/response.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/shared/inbox/modified"
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Messages
+ module Ack
+ class Response < JsonSchematize::Generator
+ # Response is the Shared::Inbox::Modified schema
+ # We'll use it directly in the controller
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/messages/delete/request.rb b/lib/command_tower/schema/inbox/messages/delete/request.rb
new file mode 100644
index 0000000..298bc31
--- /dev/null
+++ b/lib/command_tower/schema/inbox/messages/delete/request.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Messages
+ module Delete
+ class Request < JsonSchematize::Generator
+ add_field name: :ids, type: Array, required: true
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/messages/delete/response.rb b/lib/command_tower/schema/inbox/messages/delete/response.rb
new file mode 100644
index 0000000..7d61f12
--- /dev/null
+++ b/lib/command_tower/schema/inbox/messages/delete/response.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/shared/inbox/modified"
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Messages
+ module Delete
+ class Response < JsonSchematize::Generator
+ # Response is the Shared::Inbox::Modified schema
+ # We'll use it directly in the controller
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/messages/delete_by_id/request.rb b/lib/command_tower/schema/inbox/messages/delete_by_id/request.rb
new file mode 100644
index 0000000..43f92b4
--- /dev/null
+++ b/lib/command_tower/schema/inbox/messages/delete_by_id/request.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Messages
+ module DeleteById
+ class Request < JsonSchematize::Generator
+ schema_default option: :dig_type, value: :string
+
+ add_field name: :id, type: Integer, required: false
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/messages/delete_by_id/response.rb b/lib/command_tower/schema/inbox/messages/delete_by_id/response.rb
new file mode 100644
index 0000000..a4fd632
--- /dev/null
+++ b/lib/command_tower/schema/inbox/messages/delete_by_id/response.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/shared/inbox/modified"
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Messages
+ module DeleteById
+ class Response < JsonSchematize::Generator
+ # Response is the Shared::Inbox::Modified schema
+ # We'll use it directly in the controller
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/messages/message/request.rb b/lib/command_tower/schema/inbox/messages/message/request.rb
new file mode 100644
index 0000000..7c314f8
--- /dev/null
+++ b/lib/command_tower/schema/inbox/messages/message/request.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Messages
+ module Message
+ class Request < JsonSchematize::Generator
+ schema_default option: :dig_type, value: :string
+
+ add_field name: :id, type: Integer, required: false
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/messages/message/response.rb b/lib/command_tower/schema/inbox/messages/message/response.rb
new file mode 100644
index 0000000..75258c9
--- /dev/null
+++ b/lib/command_tower/schema/inbox/messages/message/response.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/entities/inbox/message_entity"
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Messages
+ module Message
+ class Response < JsonSchematize::Generator
+ # Response is the Entities::Inbox::MessageEntity schema
+ # We'll use it directly in the controller
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/messages/metadata/request.rb b/lib/command_tower/schema/inbox/messages/metadata/request.rb
new file mode 100644
index 0000000..7e6953c
--- /dev/null
+++ b/lib/command_tower/schema/inbox/messages/metadata/request.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Messages
+ module Metadata
+ class Request < JsonSchematize::Generator
+ # GET endpoint - pagination handled separately
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/messages/metadata/response.rb b/lib/command_tower/schema/inbox/messages/metadata/response.rb
new file mode 100644
index 0000000..aeb599b
--- /dev/null
+++ b/lib/command_tower/schema/inbox/messages/metadata/response.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/shared/inbox/metadata"
+
+module CommandTower
+ module Schema
+ module Inbox
+ module Messages
+ module Metadata
+ class Response < JsonSchematize::Generator
+ # Response is the Shared::Inbox::Metadata schema
+ # We'll use it directly in the controller
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/inbox/metadata.rb b/lib/command_tower/schema/inbox/metadata.rb
deleted file mode 100644
index 785f83b..0000000
--- a/lib/command_tower/schema/inbox/metadata.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-require "command_tower/schema/inbox/message_entity"
-require "command_tower/schema/pagination"
-
-module CommandTower
- module Schema
- module Inbox
- class Metadata < JsonSchematize::Generator
- # schema_default option: :dig_type, value: :string
-
- add_field name: :entities, array_of_types: true, type: CommandTower::Schema::Inbox::MessageEntity, required: false
- add_field name: :count, type: Integer
- add_field name: :pagination, type: CommandTower::Schema::Pagination, required: false
- end
- end
- end
-end
diff --git a/lib/command_tower/schema/inbox/modified.rb b/lib/command_tower/schema/inbox/modified.rb
deleted file mode 100644
index 205e199..0000000
--- a/lib/command_tower/schema/inbox/modified.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-module CommandTower
- module Schema
- module Inbox
- class Modified < JsonSchematize::Generator
- add_field name: :type, type: Symbol
- add_field name: :ids, type: Array
- add_field name: :count, type: Integer
- end
- end
- end
-end
diff --git a/lib/command_tower/schema/page.rb b/lib/command_tower/schema/page.rb
deleted file mode 100644
index 960941a..0000000
--- a/lib/command_tower/schema/page.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-module CommandTower
- module Schema
- class Page < JsonSchematize::Generator
- add_field name: :cursor, type: Integer
- add_field name: :limit, type: Integer
- add_field name: :query, type: String
- end
- end
-end
diff --git a/lib/command_tower/schema/pagination.rb b/lib/command_tower/schema/pagination.rb
deleted file mode 100644
index b63940d..0000000
--- a/lib/command_tower/schema/pagination.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-require "command_tower/schema/page"
-
-module CommandTower
- module Schema
- class Pagination < JsonSchematize::Generator
- add_field name: :current, type: Page
- add_field name: :next, type: Page, required: false
- add_field name: :count_available, type: Integer, required: false
- add_field name: :current_page, type: Integer, required: false
- add_field name: :remaining_pages, type: Integer, required: false
- add_field name: :total_pages, type: Integer, required: false
- end
- end
-end
diff --git a/lib/command_tower/schema/plain_text/create_user_request.rb b/lib/command_tower/schema/plain_text/create_user_request.rb
deleted file mode 100644
index 4a3be75..0000000
--- a/lib/command_tower/schema/plain_text/create_user_request.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-module CommandTower
- module Schema
- module PlainText
- class CreateUserRequest < JsonSchematize::Generator
- schema_default option: :dig_type, value: :string
-
- add_field name: :first_name, type: String, required: false
- add_field name: :last_name, type: String, required: false
- add_field name: :username, type: String, required: false
- add_field name: :email, type: String, required: false
- add_field name: :password, type: String, required: false
- add_field name: :password_confirmation, type: String, required: false
- end
- end
- end
-end
diff --git a/lib/command_tower/schema/plain_text/create_user_response.rb b/lib/command_tower/schema/plain_text/create_user_response.rb
deleted file mode 100644
index 3bb199a..0000000
--- a/lib/command_tower/schema/plain_text/create_user_response.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-module CommandTower
- module Schema
- module PlainText
- class CreateUserResponse < JsonSchematize::Generator
- add_field name: :full_name, type: String
- add_field name: :first_name, type: String
- add_field name: :last_name, type: String
- add_field name: :username, type: String
- add_field name: :email, type: String
- add_field name: :msg, type: String
- end
- end
- end
-end
-
diff --git a/lib/command_tower/schema/plain_text/email_verify_request.rb b/lib/command_tower/schema/plain_text/email_verify_request.rb
deleted file mode 100644
index bec1281..0000000
--- a/lib/command_tower/schema/plain_text/email_verify_request.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-module CommandTower
- module Schema
- module PlainText
- class EmailVerifyRequest < JsonSchematize::Generator
- add_field name: :code, type: String
- end
- end
- end
-end
diff --git a/lib/command_tower/schema/plain_text/email_verify_response.rb b/lib/command_tower/schema/plain_text/email_verify_response.rb
deleted file mode 100644
index 4c35402..0000000
--- a/lib/command_tower/schema/plain_text/email_verify_response.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-module CommandTower
- module Schema
- module PlainText
- class EmailVerifyResponse< JsonSchematize::Generator
- add_field name: :message, type: String
- end
- end
- end
-end
diff --git a/lib/command_tower/schema/plain_text/email_verify_send_request.rb b/lib/command_tower/schema/plain_text/email_verify_send_request.rb
deleted file mode 100644
index 6139f05..0000000
--- a/lib/command_tower/schema/plain_text/email_verify_send_request.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-module CommandTower
- module Schema
- module PlainText
- class EmailVerifySendRequest < JsonSchematize::Generator; end
- end
- end
-end
diff --git a/lib/command_tower/schema/plain_text/email_verify_send_response.rb b/lib/command_tower/schema/plain_text/email_verify_send_response.rb
deleted file mode 100644
index ba720c8..0000000
--- a/lib/command_tower/schema/plain_text/email_verify_send_response.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-module CommandTower
- module Schema
- module PlainText
- class EmailVerifySendResponse< JsonSchematize::Generator
- add_field name: :message, type: String
- end
- end
- end
-end
diff --git a/lib/command_tower/schema/plain_text/login_request.rb b/lib/command_tower/schema/plain_text/login_request.rb
deleted file mode 100644
index 805afcb..0000000
--- a/lib/command_tower/schema/plain_text/login_request.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-module CommandTower
- module Schema
- module PlainText
- class LoginRequest < JsonSchematize::Generator
- schema_default option: :dig_type, value: :string
-
- add_field name: :username, type: String, required: false
- add_field name: :email, type: String, required: false
- add_field name: :password, type: String, required: false
- end
- end
- end
-end
diff --git a/lib/command_tower/schema/plain_text/login_response.rb b/lib/command_tower/schema/plain_text/login_response.rb
deleted file mode 100644
index 4c0e093..0000000
--- a/lib/command_tower/schema/plain_text/login_response.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-require "command_tower/schema/user"
-
-module CommandTower
- module Schema
- module PlainText
- class LoginResponse < JsonSchematize::Generator
- add_field name: :token, type: String
- add_field name: :header_name, type: String
- add_field name: :message, type: String
- add_field name: :user, type: CommandTower::Schema::User
- end
- end
- end
-end
diff --git a/lib/command_tower/schema/shared/admin/users.rb b/lib/command_tower/schema/shared/admin/users.rb
new file mode 100644
index 0000000..0a47656
--- /dev/null
+++ b/lib/command_tower/schema/shared/admin/users.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/shared/user"
+require "command_tower/schema/shared/pagination"
+
+module CommandTower
+ module Schema
+ module Shared
+ module Admin
+ class Users < JsonSchematize::Generator
+ add_field name: :users, array_of_types: true, type: CommandTower::Schema::Shared::User
+ add_field name: :count, type: Integer, required: false
+ add_field name: :pagination, type: CommandTower::Schema::Shared::Pagination, required: false
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/shared/inbox/message_blast_metadata.rb b/lib/command_tower/schema/shared/inbox/message_blast_metadata.rb
new file mode 100644
index 0000000..8fe9adb
--- /dev/null
+++ b/lib/command_tower/schema/shared/inbox/message_blast_metadata.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/entities/inbox/message_blast_entity"
+require "command_tower/schema/shared/pagination"
+
+module CommandTower
+ module Schema
+ module Shared
+ module Inbox
+ class MessageBlastMetadata < JsonSchematize::Generator
+ add_field name: :entities, array_of_types: true, type: CommandTower::Schema::Entities::Inbox::MessageBlastEntity, required: false
+ add_field name: :count, type: Integer
+ add_field name: :pagination, type: CommandTower::Schema::Shared::Pagination, required: false
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/shared/inbox/metadata.rb b/lib/command_tower/schema/shared/inbox/metadata.rb
new file mode 100644
index 0000000..730c09b
--- /dev/null
+++ b/lib/command_tower/schema/shared/inbox/metadata.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/entities/inbox/message_entity"
+require "command_tower/schema/shared/pagination"
+
+module CommandTower
+ module Schema
+ module Shared
+ module Inbox
+ class Metadata < JsonSchematize::Generator
+ # schema_default option: :dig_type, value: :string
+
+ add_field name: :entities, array_of_types: true, type: CommandTower::Schema::Entities::Inbox::MessageEntity, required: false
+ add_field name: :count, type: Integer
+ add_field name: :pagination, type: CommandTower::Schema::Shared::Pagination, required: false
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/shared/inbox/modified.rb b/lib/command_tower/schema/shared/inbox/modified.rb
new file mode 100644
index 0000000..04b3622
--- /dev/null
+++ b/lib/command_tower/schema/shared/inbox/modified.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Shared
+ module Inbox
+ class Modified < JsonSchematize::Generator
+ add_field name: :type, type: Symbol
+ add_field name: :ids, type: Array
+ add_field name: :count, type: Integer
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/shared/page.rb b/lib/command_tower/schema/shared/page.rb
new file mode 100644
index 0000000..f8ebd34
--- /dev/null
+++ b/lib/command_tower/schema/shared/page.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Shared
+ class Page < JsonSchematize::Generator
+ add_field name: :cursor, type: Integer
+ add_field name: :limit, type: Integer
+ add_field name: :query, type: String
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/shared/pagination.rb b/lib/command_tower/schema/shared/pagination.rb
new file mode 100644
index 0000000..eb712bf
--- /dev/null
+++ b/lib/command_tower/schema/shared/pagination.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/shared/page"
+
+module CommandTower
+ module Schema
+ module Shared
+ class Pagination < JsonSchematize::Generator
+ add_field name: :current, type: Shared::Page
+ add_field name: :next, type: Shared::Page, required: false
+ add_field name: :count_available, type: Integer, required: false
+ add_field name: :current_page, type: Integer, required: false
+ add_field name: :remaining_pages, type: Integer, required: false
+ add_field name: :total_pages, type: Integer, required: false
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/shared/user.rb b/lib/command_tower/schema/shared/user.rb
new file mode 100644
index 0000000..0dd67ef
--- /dev/null
+++ b/lib/command_tower/schema/shared/user.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module Shared
+ class User < JsonSchematize::Generator
+ schema_default option: :dig_type, value: :string
+
+ def self.convert_user_object(user:)
+ attributes = CommandTower.config.user.default_attributes.map(&:to_s)
+ object = user.attributes.slice(*attributes)
+
+ new(object)
+ end
+
+ # Gets assigned during configuration phase via
+ # lib/command_tower/configuration/user/config.rb
+ def self.assign!
+ attributes = CommandTower.config.user.default_attributes
+
+ attributes.each do |attribute|
+ if metadata = ::User.attribute_to_type_mapping[attribute]
+ type = metadata[:serialized_type] ? metadata[:serialized_type] : metadata[:base]
+ type = JsonSchematize::Boolean if type == "Boolean"
+ add_field(name: attribute, type:)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/user.rb b/lib/command_tower/schema/user.rb
deleted file mode 100644
index fb657e3..0000000
--- a/lib/command_tower/schema/user.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-# frozen_string_literal: true
-
-module CommandTower
- module Schema
- class User < JsonSchematize::Generator
- schema_default option: :dig_type, value: :string
-
- def self.convert_user_object(user:)
- attributes = CommandTower.config.user.default_attributes.map(&:to_s)
- object = user.attributes.slice(*attributes)
-
- new(object)
- end
-
- # Gets assigned during configuration phase via
- # lib/command_tower/configuration/user/config.rb
- def self.assign!
- attributes = CommandTower.config.user.default_attributes
- attributes.each do |attribute|
- if metadata = ::User.attribute_to_type_mapping[attribute]
- type = metadata[:serialized_type] ? metadata[:serialized_type] : metadata[:base]
- add_field(name: attribute, type:)
- end
- end
- end
- end
- end
-end
diff --git a/lib/command_tower/schema/user/modify/request.rb b/lib/command_tower/schema/user/modify/request.rb
new file mode 100644
index 0000000..c0ae1c7
--- /dev/null
+++ b/lib/command_tower/schema/user/modify/request.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module User
+ module Modify
+ class Request < JsonSchematize::Generator
+ schema_default option: :dig_type, value: :string
+
+ add_field name: :email, type: String, required: false
+ add_field name: :email_validated, type: JsonSchematize::Boolean, required: false
+ add_field name: :first_name, type: String, required: false
+ add_field name: :last_name, type: String, required: false
+ add_field name: :username, type: String, required: false
+ add_field name: :verifier_token, type: JsonSchematize::Boolean, required: false
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/user/modify/response.rb b/lib/command_tower/schema/user/modify/response.rb
new file mode 100644
index 0000000..de92353
--- /dev/null
+++ b/lib/command_tower/schema/user/modify/response.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/shared/user"
+
+module CommandTower
+ module Schema
+ module User
+ module Modify
+ class Response < JsonSchematize::Generator
+ # Response is just the User schema
+ # We'll use Shared::User directly in the controller
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/user/show/request.rb b/lib/command_tower/schema/user/show/request.rb
new file mode 100644
index 0000000..aad863e
--- /dev/null
+++ b/lib/command_tower/schema/user/show/request.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module CommandTower
+ module Schema
+ module User
+ module Show
+ class Request < JsonSchematize::Generator
+ # GET endpoint - no request body validation needed
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/schema/user/show/response.rb b/lib/command_tower/schema/user/show/response.rb
new file mode 100644
index 0000000..f560ff7
--- /dev/null
+++ b/lib/command_tower/schema/user/show/response.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require "command_tower/schema/shared/user"
+
+module CommandTower
+ module Schema
+ module User
+ module Show
+ class Response < JsonSchematize::Generator
+ # Response is just the User schema
+ # We'll use Shared::User directly in the controller
+ end
+ end
+ end
+ end
+end
diff --git a/lib/command_tower/spec_helper.rb b/lib/command_tower/spec_helper.rb
index fbbf6d8..b1d1948 100644
--- a/lib/command_tower/spec_helper.rb
+++ b/lib/command_tower/spec_helper.rb
@@ -8,12 +8,69 @@ def set_jwt_token!(user:, with_reset: false, token: nil)
token = result.token
end
- @request.headers[CommandTower::ApplicationController::AUTHENTICATION_HEADER] = "Bearer: #{token}"
+ @request.headers[CommandTower::ApplicationController::AUTHENTICATION_HEADER] = "Bearer #{token}"
@request.headers[CommandTower::ApplicationController::AUTHENTICATION_WITH_RESET] = "true" if with_reset
end
def unset_jwt_token!
@request.headers[CommandTower::ApplicationController::AUTHENTICATION_HEADER] = nil
end
+
+ # Helper method to get login token and cookie value
+ # Uses the login service directly to avoid controller double render issues
+ # @param user [User] The user to login
+ # @param password [String] The user's password
+ # @return [Hash] Hash with :token, :cookie_value, and :cookie_header
+ def get_login_token_and_cookie(user:, password:)
+ # Authenticate user first
+ result = CommandTower::LoginStrategy::PlainText::Login.(identifier: user.username, password:)
+ raise "Login failed: #{result.msg}" unless result.success?
+
+ token = result.token
+ expires_at = CommandTower.config.jwt.ttl.from_now.to_time.to_s
+ cookie_name = CommandTower.config.jwt.cookie.name
+
+ # Create a mock response to set cookie
+ mock_response = ActionDispatch::TestResponse.new
+ CommandTower::Jwt::AuthorizationHelper.set_token(mock_response, token, expires_at: expires_at)
+ cookie_header = mock_response.headers["Set-Cookie"]
+ cookie_value = cookie_header&.match(/#{cookie_name}=([^;]+)/)&.[](1)
+
+ { token:, cookie_value:, cookie_header: }
+ end
+
+ # Helper to set CSRF cookie in request
+ def set_csrf_cookie!(token)
+ csrf_cookie_name = CommandTower.config.jwt.cookie.csrf.cookie_name
+ @request.cookies[csrf_cookie_name] = token
+ end
+
+ # Helper to set CSRF header in request
+ def set_csrf_header!(token)
+ csrf_header_name = CommandTower.config.jwt.cookie.csrf.header_name
+ @request.headers[csrf_header_name] = token
+ end
+
+ # Helper to extract CSRF cookie from response
+ def extract_csrf_cookie(response)
+ csrf_cookie_name = CommandTower.config.jwt.cookie.csrf.cookie_name
+ set_cookie_header = response.headers["Set-Cookie"]
+ return nil unless set_cookie_header
+
+ # Handle both string and array (when multiple cookies are set)
+ cookie_headers = set_cookie_header.is_a?(Array) ? set_cookie_header : [set_cookie_header]
+
+ cookie_headers.each do |header|
+ match = header.match(/#{csrf_cookie_name}=([^;]+)/)
+ return match[1] if match
+ end
+ nil
+ end
+
+ # Helper to get CSRF cookie value from request
+ def get_csrf_cookie_value
+ csrf_cookie_name = CommandTower.config.jwt.cookie.csrf.cookie_name
+ @request.cookies[csrf_cookie_name]
+ end
end
end
diff --git a/lib/command_tower/version.rb b/lib/command_tower/version.rb
index 9e1b95f..464e8fc 100644
--- a/lib/command_tower/version.rb
+++ b/lib/command_tower/version.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
module CommandTower
- VERSION = "0.4.0"
+ VERSION = "0.5.0"
end
diff --git a/lib/tasks/message_blasts.rake b/lib/tasks/message_blasts.rake
new file mode 100644
index 0000000..58bad4f
--- /dev/null
+++ b/lib/tasks/message_blasts.rake
@@ -0,0 +1,140 @@
+# frozen_string_literal: true
+
+namespace :command_tower do
+ namespace :message_blasts do
+ desc "Generate random message blasts"
+ task generate: :environment do
+ puts "\n=== CommandTower Message Blast Generator ==="
+ puts
+
+ # Get number of blasts to generate
+ print "How many message blasts to generate? (default: 5): "
+ count_input = $stdin.gets.chomp
+ count = count_input.blank? ? 5 : count_input.to_i
+ raise "Count must be a positive integer" if count <= 0
+
+ # Get available users
+ users = ::User.all
+ if users.empty?
+ puts "\n✗ No users found in the database. Please create at least one user first."
+ raise "No users available"
+ end
+
+ puts "\nGenerating #{count} message blast(s)..."
+ puts
+
+ # Sample titles and texts for variety
+ sample_titles = [
+ "Welcome to our platform!",
+ "Important Update",
+ "New Features Available",
+ "Maintenance Notice",
+ "Special Offer",
+ "System Upgrade",
+ "Community News",
+ "Product Launch",
+ "Security Alert",
+ "Thank You Message"
+ ]
+
+ sample_texts = [
+ "We're excited to have you on board! Check out our latest features.",
+ "This is an important update regarding our services. Please review carefully.",
+ "We've added new features that you might find useful. Explore them now!",
+ "Scheduled maintenance will occur this weekend. Services may be temporarily unavailable.",
+ "Don't miss out on our special offer! Limited time only.",
+ "We've upgraded our systems for better performance and reliability.",
+ "Stay connected with our community and get the latest news.",
+ "We're launching a new product that we think you'll love!",
+ "This is a security alert. Please review your account settings.",
+ "Thank you for being a valued member of our community!"
+ ]
+
+ success_count = 0
+ error_count = 0
+
+ count.times do |i|
+ begin
+ # Randomly select a user
+ user = users.sample
+
+ # Generate random title and text
+ title = sample_titles.sample
+ text = sample_texts.sample
+
+ # Determine target audience
+ # Logic:
+ # - If both false: neither (invalid state, but we'll allow it for testing)
+ # - If existing_users true: targets existing users
+ # - If new_users true: targets new users
+ # - If both true: targets all users
+
+ # Randomly decide on target audience
+ target_choice = rand(4) # 0-3 for 4 different combinations
+
+ case target_choice
+ when 0
+ # All users (both true)
+ existing_users = true
+ new_users = true
+ target_desc = "all users"
+ when 1
+ # Only existing users
+ existing_users = true
+ new_users = false
+ target_desc = "existing users only"
+ when 2
+ # Only new users
+ existing_users = false
+ new_users = true
+ target_desc = "new users only"
+ when 3
+ # Neither (edge case for testing)
+ existing_users = false
+ new_users = false
+ target_desc = "no users (testing edge case)"
+ end
+
+ # Create the message blast using the service
+ result = CommandTower::InboxService::Blast::Upsert.(
+ user: user,
+ title: title,
+ text: text,
+ existing_users: existing_users,
+ new_users: new_users
+ )
+
+ if result.success?
+ message_blast = result.message_blast
+ success_count += 1
+ puts "✓ [#{i + 1}/#{count}] Created message blast ##{message_blast.id}"
+ puts " Title: #{title}"
+ puts " Created by: #{user.username || user.email} (ID: #{user.id})"
+ puts " Target: #{target_desc}"
+ puts " Existing users: #{existing_users}, New users: #{new_users}"
+ puts
+ else
+ error_count += 1
+ puts "✗ [#{i + 1}/#{count}] Failed to create message blast:"
+ puts " Error: #{result.msg}"
+ if result.respond_to?(:invalid_argument_hash) && result.invalid_argument_hash
+ result.invalid_argument_hash.each do |field, errors|
+ puts " - #{field}: #{Array(errors).join(', ')}"
+ end
+ end
+ puts
+ end
+ rescue StandardError => e
+ error_count += 1
+ puts "✗ [#{i + 1}/#{count}] Error creating message blast: #{e.message}"
+ puts
+ end
+ end
+
+ puts "=== Summary ==="
+ puts "Successfully created: #{success_count}"
+ puts "Errors: #{error_count}"
+ puts "Total: #{count}"
+ end
+ end
+end
diff --git a/lib/tasks/users.rake b/lib/tasks/users.rake
new file mode 100644
index 0000000..9f6ca54
--- /dev/null
+++ b/lib/tasks/users.rake
@@ -0,0 +1,119 @@
+# frozen_string_literal: true
+
+namespace :command_tower do
+ namespace :users do
+ desc "Create a new user with username, email, password, and optional roles"
+ task create: :environment do
+ # Initialize authorization to load available roles
+ begin
+ CommandTower::Authorization.default_defined! if CommandTower::Authorization.respond_to?(:default_defined!)
+ rescue StandardError => e
+ puts "Warning: Could not initialize authorization roles: #{e.message}"
+ puts "Continuing without role selection..."
+ end
+
+ puts "\n=== CommandTower User Creation ==="
+ puts
+
+ # Get basic user information
+ print "Username: "
+ username = $stdin.gets.chomp
+ raise "Username cannot be blank" if username.blank?
+
+ print "Email: "
+ email = $stdin.gets.chomp
+ raise "Email cannot be blank" if email.blank?
+
+ print "Password: "
+ password = $stdin.noecho(&:gets).chomp
+ puts
+ raise "Password cannot be blank" if password.blank?
+
+ print "First Name: "
+ first_name = $stdin.gets.chomp
+ raise "First name cannot be blank" if first_name.blank?
+
+ print "Last Name: "
+ last_name = $stdin.gets.chomp
+ raise "Last name cannot be blank" if last_name.blank?
+
+ # Check if user should be admin
+ print "Is this an admin user? (y/n): "
+ is_admin = $stdin.gets.chomp.downcase == "y"
+
+ # Get available roles
+ roles_to_assign = []
+ available_roles = []
+
+ begin
+ available_roles = CommandTower::Authorization::Role.roles.keys.sort
+ rescue StandardError => e
+ puts "Warning: Could not load available roles: #{e.message}"
+ end
+
+ if is_admin
+ if available_roles.include?("admin")
+ roles_to_assign << "admin"
+ else
+ puts "Warning: 'admin' role is not available in the RBAC configuration."
+ end
+ end
+
+ # Allow selection of additional roles if any are available
+ if available_roles.any?
+ puts "\nAvailable RBAC Roles:"
+ available_roles.each_with_index do |role, index|
+ begin
+ role_obj = CommandTower::Authorization::Role.roles[role]
+ description = role_obj&.description || "No description"
+ puts " #{index + 1}. #{role} - #{description}"
+ rescue StandardError
+ puts " #{index + 1}. #{role}"
+ end
+ end
+
+ puts "\nSelect additional roles (enter numbers separated by commas, or press Enter to skip):"
+ print "Roles: "
+ role_selection = $stdin.gets.chomp
+
+ unless role_selection.blank?
+ selected_indices = role_selection.split(",").map(&:strip).map(&:to_i)
+ selected_indices.each do |index|
+ if index > 0 && index <= available_roles.length
+ selected_role = available_roles[index - 1]
+ roles_to_assign << selected_role unless roles_to_assign.include?(selected_role)
+ end
+ end
+ end
+ else
+ puts "\nNo additional RBAC roles are configured."
+ end
+
+ # Create the user
+ puts "\nCreating user..."
+ user = ::User.new(
+ username: username,
+ email: email,
+ password: password,
+ first_name: first_name,
+ last_name: last_name,
+ roles: roles_to_assign.uniq
+ )
+
+ if user.save
+ puts "\n✓ User created successfully!"
+ puts " Username: #{user.username}"
+ puts " Email: #{user.email}"
+ puts " Name: #{user.full_name}"
+ puts " Roles: #{user.roles.empty? ? 'none' : user.roles.join(', ')}"
+ puts " ID: #{user.id}"
+ else
+ puts "\n✗ Failed to create user:"
+ user.errors.full_messages.each do |error|
+ puts " - #{error}"
+ end
+ raise "User creation failed"
+ end
+ end
+ end
+end
diff --git a/rails_app/config/initializers/command_tower.rb b/rails_app/config/initializers/command_tower.rb
index 9422552..948cfc2 100644
--- a/rails_app/config/initializers/command_tower.rb
+++ b/rails_app/config/initializers/command_tower.rb
@@ -23,6 +23,70 @@
# HMAC is the only algorithm supported. This is the secret key to encrypt he JWT token: [String]
# config.jwt.hmac_secret = ENV.fetch("SECRET_KEY_BASE","Thi$IsASeccretIwi::CH&ang3")
+ # ### Block to configure Cookie ###
+ # HttpOnly cookie configuration for JWT token persistence
+ # When using the block, the enabled flag will automatically get set to true
+ # config.jwt.with_cookie do |cookie_config|
+ # Enable HttpOnly cookie support for JWT tokens. When disabled, cookies are never set, read, or refreshed: [TrueClass, FalseClass]
+ # cookie_config.enabled = false
+
+ # Name of the JWT cookie: [String]
+ # cookie_config.name = "ct_jwt"
+
+ # SameSite attribute for the cookie (:lax, :strict, or :none): [Symbol]
+ # cookie_config.same_site = :lax
+
+ # Secure flag for the cookie (HTTPS only). Defaults to false in development, true in production: [TrueClass, FalseClass]
+ # cookie_config.secure = false
+
+ # HttpOnly flag for the cookie (prevents JavaScript access): [TrueClass, FalseClass]
+ # cookie_config.httponly = true
+
+ # Path for the cookie: [String]
+ # cookie_config.path = "/"
+
+ # Domain for the cookie (nil means host-only): [String, NilClass]
+ # cookie_config.domain = ""
+
+ # Time to live for the cookie (defaults to JWT TTL): [ActiveSupport::Duration]
+ # cookie_config.ttl = 7.days
+
+ # ### Block to configure Csrf ###
+ # Double-submit CSRF protection configuration for cookie-authenticated requests
+ # When using the block, the enabled flag will automatically get set to true
+ # cookie_config.with_csrf do |csrf_config|
+ # Enable double-submit CSRF protection for cookie-authenticated requests: [TrueClass, FalseClass]
+ # csrf_config.enabled = false
+
+ # Name of the CSRF token cookie (must NOT be HttpOnly): [String]
+ # csrf_config.cookie_name = "ct_csrf"
+
+ # Name of the CSRF token header: [String]
+ # csrf_config.header_name = "X-CSRF-Token"
+
+ # Rotate CSRF token on login: [TrueClass, FalseClass]
+ # csrf_config.rotate_on_login = true
+
+ # Rotate CSRF token when JWT token is reset: [TrueClass, FalseClass]
+ # csrf_config.rotate_on_reset = true
+
+ # SameSite attribute for CSRF cookie (nil inherits from JWT cookie): [Symbol, NilClass]
+ # csrf_config.same_site =
+
+ # Secure flag for CSRF cookie (nil inherits from JWT cookie): [TrueClass, FalseClass, NilClass]
+ # csrf_config.secure =
+
+ # Path for the CSRF cookie (nil inherits from JWT cookie): [String, NilClass]
+ # csrf_config.path = ""
+
+ # Domain for the CSRF cookie (nil inherits from JWT cookie): [String, NilClass]
+ # csrf_config.domain = ""
+
+ # Time to live for the CSRF cookie: [ActiveSupport::Duration]
+ # csrf_config.ttl = 7.days
+ # end
+ # end
+
# ###########################
# # #
# ######### Login #########
@@ -66,6 +130,32 @@
# The length of the verify code sent via email.: [Integer]
# email_verify_config.verify_code_length = 6
+
+ # Custom template name to use for the email verification email. Allows using a different view template without creating a custom mailer class. The template should be located at app/views/command_tower/email_verification_mailer/{template_name}.html.erb. Defaults to 'verify_email': [String, NilClass]
+ # email_verify_config.custom_template_name = ""
+ # end
+
+ # ### Block to configure Password Reset ###
+ # Enable and change Password Reset for User/Password Login strategy.
+ # When using the block, the enabled flag will automatically get set to true
+ # plain_text_config.with_password_reset do |password_reset_config|
+ # Password reset feature allows users to request password reset via email. By default this is enabled: [FalseClass, TrueClass]
+ # password_reset_config.enabled = true
+
+ # When the password reset token is sent, how long will that token be valid for. By default, this is set to 1 hour: [ActiveSupport::Duration]
+ # password_reset_config.token_valid_for = 10.minutes
+
+ # The length of the password reset token sent via email.: [Integer]
+ # password_reset_config.token_length = 32
+
+ # Custom template name to use for the password reset email. Allows using a different view template without creating a custom mailer class. The template should be located at app/views/command_tower/password_reset_mailer/{template_name}.html.erb. Defaults to 'reset_password': [String, NilClass]
+ # password_reset_config.custom_template_name = ""
+
+ # When enabled, requires users to provide both token and email when validating or resetting password. This adds an additional security layer to prevent brute force attacks. Defaults to false for backward compatibility.: [FalseClass, TrueClass]
+ # password_reset_config.require_email = false
+
+ # The path (not full URL) to the frontend reset password page. This path will be appended to CommandTower.config.app.composed_url to form the full URL. Used in email template for the reset link. Defaults to '/reset-password': [String]
+ # password_reset_config.reset_password_path = "/reset-password"
# end
# Max Length for Password: [Integer]
@@ -160,7 +250,7 @@
# config.application.url = "http://localhost"
# When composing SSO's or verification URL's, this is the PORT for the application: [String, NilClass]
- # config.application.port = "7777"
+ # config.application.port = ""
# The fully composed URL including the port number when needed. This Config variable is not needed as it is composed of the `url` and `port` composed values: [String]
# config.application.composed_url = # Composed String of the URL and PORT. Override this with caution
@@ -189,10 +279,10 @@
# config.user.additional_attributes_for_change = []
# [Not Recommended for change] Default attributes that are allowed to change: [Array]
- # config.user.default_attributes_for_change = email first_name last_name last_known_timezone username verifier_token
+ # config.user.default_attributes_for_change = email first_name last_name username verifier_token
# [Not Recommended for change] Default attributes that are shown to the user: [Array]
- # config.user.default_attributes = email first_name last_name last_known_timezone username verifier_token id roles created_at
+ # config.user.default_attributes = email first_name last_name username verifier_token created_at email_validated id last_known_timezone roles
# ###########################
# # #
diff --git a/spec/controllers/command_tower/admin_controller_spec.rb b/spec/controllers/command_tower/admin_controller_spec.rb
index 382f224..dae5a0c 100644
--- a/spec/controllers/command_tower/admin_controller_spec.rb
+++ b/spec/controllers/command_tower/admin_controller_spec.rb
@@ -56,7 +56,13 @@
it "returns user array" do
subject
- expect(response_body["users"]).to all(include(*CommandTower::Schema::User.introspect.keys))
+ expect(response_body["users"]).to all(include(*CommandTower::Schema::Shared::User.introspect.keys))
+ end
+
+ it "does not return verifier_token in user objects" do
+ subject
+
+ expect(response_body["users"]).to all(satisfy { |u| !u.key?("verifier_token") })
end
context "with pagination" do
@@ -162,7 +168,7 @@
it "returns new user keys" do
subject
- expect(response_body.keys).to include(*CommandTower::Schema::User.introspect.keys)
+ expect(response_body.keys).to include(*CommandTower::Schema::Shared::User.introspect.keys)
end
it "has correct key change" do
@@ -234,7 +240,49 @@
it "returns new user keys" do
subject
- expect(response_body.keys).to include(*CommandTower::Schema::User.introspect.keys)
+ expect(response_body.keys).to include(*CommandTower::Schema::Shared::User.introspect.keys)
+ end
+
+ it "does not return verifier_token in response" do
+ subject
+
+ expect(response_body).to_not have_key("verifier_token")
+ end
+
+ it "changes verifier_token in database" do
+ original_token = user.reload.verifier_token
+ subject
+
+ expect(user.reload.verifier_token).to_not eq(original_token)
+ expect(user.reload.verifier_token).to be_present
+ end
+
+ it "updates verifier_token_last_reset timestamp" do
+ original_timestamp = user.reload.verifier_token_last_reset
+ subject
+
+ new_timestamp = user.reload.verifier_token_last_reset
+ expect(new_timestamp).to be_present
+ if original_timestamp
+ expect(new_timestamp).to be >= original_timestamp
+ end
+ end
+
+ it "invalidates existing JWT tokens for the modified user" do
+ # Create a token for the user being modified (not the admin)
+ user_token = CommandTower::Jwt::LoginCreate.(user:).token
+
+ # Verify the token works before reset by authenticating with it
+ authenticate_result = CommandTower::Jwt::AuthenticateUser.(token: user_token)
+ expect(authenticate_result.success?).to be(true)
+
+ # Reset verifier_token via admin modify
+ subject
+
+ # Try to authenticate with the old token - it should fail
+ authenticate_result = CommandTower::Jwt::AuthenticateUser.(token: user_token)
+ expect(authenticate_result.failure?).to be(true)
+ expect(authenticate_result.msg).to include("Unauthorized")
end
include_examples "with invalid user to modify"
@@ -294,5 +342,11 @@
expect(response_body["roles"]).to include(*roles)
end
+
+ it "does not return verifier_token in response" do
+ subject
+
+ expect(response_body).to_not have_key("verifier_token")
+ end
end
end
diff --git a/spec/controllers/command_tower/application_controller_spec.rb b/spec/controllers/command_tower/application_controller_spec.rb
new file mode 100644
index 0000000..b92bf9b
--- /dev/null
+++ b/spec/controllers/command_tower/application_controller_spec.rb
@@ -0,0 +1,186 @@
+# frozen_string_literal: true
+
+# Test ApplicationController's authenticate_user! method through UserController
+# UserController inherits from ApplicationController and uses authenticate_user! as a before_action
+# This allows us to test the authentication header parsing edge cases
+RSpec.describe CommandTower::UserController, type: :controller do
+ let(:response_body) { JSON.parse(response.body) }
+ let(:user) { create(:user) }
+
+ describe "ApplicationController#authenticate_user! with malformed Authorization headers" do
+ subject(:authenticate) { get(:show) }
+
+ context "when Authorization header is missing" do
+ before { unset_jwt_token! }
+
+ it "returns 401 status" do
+ authenticate
+
+ expect(response.status).to eq(401)
+ end
+
+ it "returns 'Bearer token missing' message" do
+ authenticate
+
+ expect(response_body["message"]).to eq("Bearer token missing")
+ end
+ end
+
+ context "when Authorization header uses old format 'Bearer:token'" do
+ before do
+ @request.headers[CommandTower::ApplicationController::AUTHENTICATION_HEADER] = "Bearer:some_token_value"
+ end
+
+ it "returns 401 status instead of crashing" do
+ expect { authenticate }.not_to raise_error
+ expect(response.status).to eq(401)
+ end
+
+ it "returns 'Invalid Bearer token format' message" do
+ authenticate
+
+ expect(response_body["message"]).to eq("Invalid Bearer token format")
+ end
+ end
+
+ context "when Authorization header is missing 'Bearer ' prefix" do
+ before do
+ @request.headers[CommandTower::ApplicationController::AUTHENTICATION_HEADER] = "some_token_value"
+ end
+
+ it "returns 401 status instead of crashing" do
+ expect { authenticate }.not_to raise_error
+ expect(response.status).to eq(401)
+ end
+
+ it "returns 'Invalid Bearer token format' message" do
+ authenticate
+
+ expect(response_body["message"]).to eq("Invalid Bearer token format")
+ end
+ end
+
+ context "when Authorization header has 'Bearer ' but empty token" do
+ before do
+ @request.headers[CommandTower::ApplicationController::AUTHENTICATION_HEADER] = "Bearer "
+ end
+
+ it "returns 401 status instead of crashing" do
+ expect { authenticate }.not_to raise_error
+ expect(response.status).to eq(401)
+ end
+
+ it "returns 'Invalid Bearer token format' message" do
+ authenticate
+
+ expect(response_body["message"]).to eq("Invalid Bearer token format")
+ end
+ end
+
+ context "when Authorization header has 'Bearer ' but only whitespace token" do
+ before do
+ @request.headers[CommandTower::ApplicationController::AUTHENTICATION_HEADER] = "Bearer "
+ end
+
+ it "returns 401 status instead of crashing" do
+ expect { authenticate }.not_to raise_error
+ expect(response.status).to eq(401)
+ end
+
+ it "returns 'Invalid Bearer token format' message" do
+ authenticate
+
+ expect(response_body["message"]).to eq("Invalid Bearer token format")
+ end
+ end
+
+ context "when Authorization header has malformed format without space" do
+ before do
+ @request.headers[CommandTower::ApplicationController::AUTHENTICATION_HEADER] = "Bearertoken"
+ end
+
+ it "returns 401 status instead of crashing" do
+ expect { authenticate }.not_to raise_error
+ expect(response.status).to eq(401)
+ end
+
+ it "returns 'Invalid Bearer token format' message" do
+ authenticate
+
+ expect(response_body["message"]).to eq("Invalid Bearer token format")
+ end
+ end
+
+ context "when Authorization header has valid format 'Bearer token'" do
+ before do
+ set_jwt_token!(user: user)
+ end
+
+ it "does not crash and processes authentication" do
+ expect { authenticate }.not_to raise_error
+ # Should either succeed (200) or fail with proper JWT validation error (401), but not crash
+ expect([200, 401]).to include(response.status)
+ end
+ end
+
+ context "when Authorization header uses lowercase 'bearer' prefix (case-insensitive)" do
+ before do
+ token = CommandTower::Jwt::LoginCreate.(user: user).token
+ @request.headers[CommandTower::ApplicationController::AUTHENTICATION_HEADER] = "bearer #{token}"
+ end
+
+ it "accepts lowercase bearer prefix and processes authentication" do
+ expect { authenticate }.not_to raise_error
+ # Should either succeed (200) or fail with proper JWT validation error (401), but not crash
+ expect([200, 401]).to include(response.status)
+ end
+ end
+
+ context "when Authorization header has multiple spaces after 'Bearer'" do
+ before do
+ token = CommandTower::Jwt::LoginCreate.(user: user).token
+ @request.headers[CommandTower::ApplicationController::AUTHENTICATION_HEADER] = "Bearer #{token}"
+ end
+
+ it "accepts multiple spaces and processes authentication" do
+ expect { authenticate }.not_to raise_error
+ # Should either succeed (200) or fail with proper JWT validation error (401), but not crash
+ expect([200, 401]).to include(response.status)
+ end
+ end
+
+ context "when Authorization header uses invalid prefix 'Token'" do
+ before do
+ @request.headers[CommandTower::ApplicationController::AUTHENTICATION_HEADER] = "Token some_token_value"
+ end
+
+ it "returns 401 status instead of crashing" do
+ expect { authenticate }.not_to raise_error
+ expect(response.status).to eq(401)
+ end
+
+ it "returns 'Invalid Bearer token format' message" do
+ authenticate
+
+ expect(response_body["message"]).to eq("Invalid Bearer token format")
+ end
+ end
+
+ context "when Authorization header has 'Bearer' but no token (missing token)" do
+ before do
+ @request.headers[CommandTower::ApplicationController::AUTHENTICATION_HEADER] = "Bearer"
+ end
+
+ it "returns 401 status instead of crashing" do
+ expect { authenticate }.not_to raise_error
+ expect(response.status).to eq(401)
+ end
+
+ it "returns 'Invalid Bearer token format' message" do
+ authenticate
+
+ expect(response_body["message"]).to eq("Invalid Bearer token format")
+ end
+ end
+ end
+end
diff --git a/spec/controllers/command_tower/auth/plain_text_controller_spec.rb b/spec/controllers/command_tower/auth/plain_text_controller_spec.rb
index bad0b0f..da9a160 100644
--- a/spec/controllers/command_tower/auth/plain_text_controller_spec.rb
+++ b/spec/controllers/command_tower/auth/plain_text_controller_spec.rb
@@ -229,20 +229,18 @@
let(:params) do
{
- username:,
- email:,
+ identifier:,
password: password_input,
}.compact
end
let(:user) { create(:user, password:) }
let(:password) { Faker::Alphanumeric.alpha(number: 20) }
let(:password_input) { password }
- let(:username) { nil }
- let(:email) { nil }
+ let(:identifier) { nil }
context "with correct login" do
- context "with email" do
- let(:email) { user.email }
+ context "when identifier is email" do
+ let(:identifier) { user.email }
it "returns success" do
subject
@@ -255,10 +253,15 @@
expect(response_body["token"]).to be_present
expect(response_body["header_name"]).to eq(CommandTower::ApplicationController::AUTHENTICATION_HEADER)
end
+
+ it "does not return verifier_token in user object" do
+ subject
+ expect(response_body["user"]).to_not have_key("verifier_token")
+ end
end
- context "with username" do
- let(:username) { user.username }
+ context "when identifier is username" do
+ let(:identifier) { user.username }
it "returns success" do
subject
@@ -271,43 +274,595 @@
expect(response_body["token"]).to be_present
expect(response_body["header_name"]).to eq(CommandTower::ApplicationController::AUTHENTICATION_HEADER)
end
+
+ it "does not return verifier_token in user object" do
+ subject
+ expect(response_body["user"]).to_not have_key("verifier_token")
+ end
end
end
context "with incorrect arguments" do
- context "with both login keys provided" do
- let(:username) { user.username }
+ context "with missing identifier" do
+ let(:identifier) { nil }
+
+ include_examples "CommandTower::Schema::Error:InvalidArguments examples", 401, "Parameter [identifier] is required but not present", [:identifier]
+ end
+
+ context "with missing password" do
+ let(:identifier) { user.username }
+ let(:password_input) { nil }
+
+ include_examples "CommandTower::Schema::Error:InvalidArguments examples", 401, "Parameter [password] is required but not present", [:password]
+ end
+ end
+
+ context "when failed login" do
+ context "with incorrect identifier" do
+ let(:identifier) { "not a valid identifier" }
+ include_examples "CommandTower::Schema::Error:InvalidArguments examples", 401, "Unauthorized Access. Incorrect Credentials", [:identifier, :password]
+ end
+ end
+ end
+
+ describe "POST: password_forgot_send_post" do
+ subject(:password_forgot_send_post) { post(:password_forgot_send_post, params:) }
+
+ let(:params) { { email: }.compact }
+ let(:email) { Faker::Internet.email }
+ let(:user) { create(:user, email: email) }
+
+ context "with existing user" do
+ before { user }
+
+ it "returns 200" do
+ subject
+ expect(response.status).to eq(200)
+ end
+
+ it "sets message" do
+ subject
+ expect(response_body["message"]).to eq("If an account exists with that email, a password reset link has been sent.")
+ end
+
+ it "sends email" do
+ expect { subject }.to change { ActionMailer::Base.deliveries.count }.by(1)
+ end
+ end
+
+ context "with non-existent user" do
+ let(:email) { "nonexistent@example.com" }
+
+ it "returns 200" do
+ subject
+ expect(response.status).to eq(200)
+ end
+
+ it "sets message" do
+ subject
+ expect(response_body["message"]).to eq("If an account exists with that email, a password reset link has been sent.")
+ end
+
+ it "does not send email" do
+ expect { subject }.not_to change { ActionMailer::Base.deliveries.count }
+ end
+ end
+
+ context "with invalid email" do
+ let(:email) { "not-an-email" }
+
+ include_examples "CommandTower::Schema::Error:InvalidArguments examples", 400, "Invalid email address", :email
+ end
+
+ context "with missing email" do
+ let(:email) { nil }
+
+ include_examples "CommandTower::Schema::Error:InvalidArguments examples", 400, /Parameter \[email\]/, [:email]
+ end
+
+ context "with email delivery failure" do
+ before do
+ user
+ allow_any_instance_of(CommandTower::PasswordResetMailer).to receive(:reset_password).and_raise(StandardError)
+ end
+
+ it "still returns 200" do
+ subject
+ expect(response.status).to eq(200)
+ end
+
+ it "sets message" do
+ subject
+ expect(response_body["message"]).to eq("If an account exists with that email, a password reset link has been sent.")
+ end
+ end
+ end
+
+ describe "POST: password_forgot_validate_post" do
+ subject(:password_forgot_validate_post) { post(:password_forgot_validate_post, params:) }
+
+ let(:user) { create(:user) }
+ let(:token) do
+ result = CommandTower::Secrets::Generate.(
+ user: user,
+ secret_length: 32,
+ reason: CommandTower::Secrets::PASSWORD_RESET,
+ use_count_max: 1,
+ death_time: 1.hour,
+ type: CommandTower::Secrets::ALPHANUMERIC,
+ cleanse: false
+ )
+ result.secret
+ end
+ let(:email) { nil }
+ let(:params) { { token: token, email: email }.compact }
+
+ context "with valid token" do
+ it "returns 200" do
+ subject
+ expect(response.status).to eq(200)
+ end
+
+ it "sets valid to true" do
+ subject
+ expect(response_body["valid"]).to eq(true)
+ end
+
+ it "sets expires_at" do
+ subject
+ expect(response_body["expires_at"]).to be_present
+ end
+ end
+
+ context "with require_email: false (backward compatibility)" do
+ before do
+ allow(CommandTower.config.login.plain_text.password_reset).to receive(:require_email).and_return(false)
+ end
+
+ context "when email is not provided" do
+ let(:email) { nil }
+
+ it "returns 200" do
+ subject
+ expect(response.status).to eq(200)
+ end
+
+ it "sets valid to true" do
+ subject
+ expect(response_body["valid"]).to eq(true)
+ end
+ end
+
+ context "when email is provided and matches" do
let(:email) { user.email }
- include_examples "CommandTower::Schema::Error:InvalidArguments examples", 401, "Composite Key failure for login_key", [:login_key]
+ it "returns 200" do
+ subject
+ expect(response.status).to eq(200)
+ end
+
+ it "sets valid to true" do
+ subject
+ expect(response_body["valid"]).to eq(true)
+ end
end
- context "when no login key provided" do
- let(:username) { nil }
+ context "when email is provided but does not match" do
+ let(:email) { "wrong@example.com" }
+
+ it "returns 401" do
+ subject
+ expect(response.status).to eq(401)
+ end
+
+ it "sets message" do
+ subject
+ expect(response_body["message"]).to eq("Invalid token")
+ end
+ end
+ end
+
+ context "with require_email: true" do
+ before do
+ allow(CommandTower.config.login.plain_text.password_reset).to receive(:require_email).and_return(true)
+ end
+
+ context "when email is not provided" do
let(:email) { nil }
- include_examples "CommandTower::Schema::Error:InvalidArguments examples", 401, "Composite Key failure for login_key", [:login_key]
+ it "returns 400" do
+ subject
+ expect(response.status).to eq(400)
+ end
+
+ it "sets message" do
+ subject
+ expect(response_body["message"]).to eq("Email is required")
+ end
end
- context "with missing password" do
- let(:username) { user.username }
- let(:password_input) { nil }
+ context "when email is provided and matches" do
+ let(:email) { user.email }
- include_examples "CommandTower::Schema::Error:InvalidArguments examples", 401, "Parameter [password] is required but not present", [:password]
+ it "returns 200" do
+ subject
+ expect(response.status).to eq(200)
+ end
+
+ it "sets valid to true" do
+ subject
+ expect(response_body["valid"]).to eq(true)
+ end
+ end
+
+ context "when email is provided but does not match" do
+ let(:email) { "wrong@example.com" }
+
+ it "returns 401" do
+ subject
+ expect(response.status).to eq(401)
+ end
+
+ it "sets message" do
+ subject
+ expect(response_body["message"]).to eq("Invalid token")
+ end
end
end
- context "when failed login" do
- context "with incorrect login key" do
- context "with email" do
- let(:email) { "not a valid email input" }
- include_examples "CommandTower::Schema::Error:InvalidArguments examples", 401, "Unauthorized Access. Incorrect Credentials", [:email, :password]
+ context "with invalid token" do
+ let(:token) { "invalid_token_12345" }
+
+ it "returns 401" do
+ subject
+ expect(response.status).to eq(401)
+ end
+
+ it "sets message" do
+ subject
+ expect(response_body["message"]).to eq("Invalid token")
+ end
+ end
+
+ context "with expired token" do
+ let(:token) do
+ result = CommandTower::Secrets::Generate.(
+ user: user,
+ secret_length: 32,
+ reason: CommandTower::Secrets::PASSWORD_RESET,
+ use_count_max: 1,
+ death_time: 1.hour,
+ type: CommandTower::Secrets::ALPHANUMERIC,
+ cleanse: false
+ )
+ secret = result.secret
+ # Manually expire the token by setting death_time in the past
+ user_secret = UserSecret.find_by(secret: secret)
+ user_secret.update!(death_time: 1.hour.ago)
+ secret
+ end
+
+ it "returns 401" do
+ subject
+ expect(response.status).to eq(401)
+ end
+
+ it "sets message" do
+ subject
+ expect(response_body["message"]).to eq("Invalid token")
+ end
+ end
+
+ context "with missing token" do
+ let(:token) { nil }
+
+ include_examples "CommandTower::Schema::Error:InvalidArguments examples", 400, /Parameter \[token\]/, [:token]
+ end
+ end
+
+ describe "POST: password_forgot_reset_post" do
+ subject(:password_forgot_reset_post) { post(:password_forgot_reset_post, params:) }
+
+ let(:user) { create(:user, password: "old_password123") }
+ let(:token) do
+ result = CommandTower::Secrets::Generate.(
+ user: user,
+ secret_length: 32,
+ reason: CommandTower::Secrets::PASSWORD_RESET,
+ use_count_max: 1,
+ death_time: 1.hour,
+ type: CommandTower::Secrets::ALPHANUMERIC,
+ cleanse: false
+ )
+ result.secret
+ end
+ let(:password) { "new_password123" }
+ let(:password_confirmation) { password }
+ let(:email) { nil }
+ let(:params) do
+ {
+ token:,
+ email:,
+ password:,
+ password_confirmation:,
+ }.compact
+ end
+
+ context "with valid token and matching passwords" do
+ it "returns 200" do
+ subject
+ expect(response.status).to eq(200)
+ end
+
+ it "sets message" do
+ subject
+ expect(response_body["message"]).to eq("Password has been successfully reset")
+ end
+
+ it "updates user password" do
+ subject
+ expect(user.reload.authenticate(password)).to be_truthy
+ end
+ end
+
+ context "with password mismatch" do
+ let(:password_confirmation) { "different_password" }
+
+ include_examples "CommandTower::Schema::Error:InvalidArguments examples", 400, "Password and confirmation do not match", :password_confirmation
+ end
+
+ context "with invalid token" do
+ let(:token) { "invalid_token_12345" }
+
+ it "returns 401" do
+ subject
+ expect(response.status).to eq(401)
+ end
+
+ it "sets message" do
+ subject
+ expect(response_body["message"]).to eq("Invalid token")
+ end
+ end
+
+ context "with expired token" do
+ let(:token) do
+ result = CommandTower::Secrets::Generate.(
+ user: user,
+ secret_length: 32,
+ reason: CommandTower::Secrets::PASSWORD_RESET,
+ use_count_max: 1,
+ death_time: 1.hour,
+ type: CommandTower::Secrets::ALPHANUMERIC,
+ cleanse: false
+ )
+ secret = result.secret
+ # Manually expire the token by setting death_time in the past
+ user_secret = UserSecret.find_by(secret: secret)
+ user_secret.update!(death_time: 1.hour.ago)
+ secret
+ end
+
+ it "returns 401" do
+ subject
+ expect(response.status).to eq(401)
+ end
+
+ it "sets message" do
+ subject
+ expect(response_body["message"]).to eq("Invalid token")
+ end
+ end
+
+ context "with missing token" do
+ let(:token) { nil }
+
+ include_examples "CommandTower::Schema::Error:InvalidArguments examples", 400, /Parameter \[token\]/, [:token]
+ end
+
+ context "with missing password" do
+ let(:password) { nil }
+
+ include_examples "CommandTower::Schema::Error:InvalidArguments examples", 400, /Parameter \[password\]/, [:password]
+ end
+
+ context "with require_email: false (backward compatibility)" do
+ before do
+ allow(CommandTower.config.login.plain_text.password_reset).to receive(:require_email).and_return(false)
+ end
+
+ context "when email is not provided" do
+ let(:email) { nil }
+
+ it "returns 200" do
+ subject
+ expect(response.status).to eq(200)
+ end
+
+ it "sets message" do
+ subject
+ expect(response_body["message"]).to eq("Password has been successfully reset")
+ end
+
+ it "updates user password" do
+ subject
+ expect(user.reload.authenticate(password)).to be_truthy
+ end
+ end
+
+ context "when email is provided and matches" do
+ let(:email) { user.email }
+
+ it "returns 200" do
+ subject
+ expect(response.status).to eq(200)
+ end
+
+ it "updates user password" do
+ subject
+ expect(user.reload.authenticate(password)).to be_truthy
+ end
+ end
+
+ context "when email is provided but does not match" do
+ let(:email) { "wrong@example.com" }
+
+ it "returns 401" do
+ subject
+ expect(response.status).to eq(401)
+ end
+
+ it "sets message" do
+ subject
+ expect(response_body["message"]).to eq("Invalid token")
end
+ end
+ end
+
+ context "with require_email: true" do
+ before do
+ allow(CommandTower.config.login.plain_text.password_reset).to receive(:require_email).and_return(true)
+ end
+
+ context "when email is not provided" do
+ let(:email) { nil }
+
+ it "returns 400" do
+ subject
+ expect(response.status).to eq(400)
+ end
+
+ it "sets message" do
+ subject
+ expect(response_body["message"]).to eq("Email is required")
+ end
+ end
+
+ context "when email is provided and matches" do
+ let(:email) { user.email }
+
+ it "returns 200" do
+ subject
+ expect(response.status).to eq(200)
+ end
+
+ it "updates user password" do
+ subject
+ expect(user.reload.authenticate(password)).to be_truthy
+ end
+ end
+
+ context "when email is provided but does not match" do
+ let(:email) { "wrong@example.com" }
- context "with username" do
- let(:username) { "not a valid email input" }
- include_examples "CommandTower::Schema::Error:InvalidArguments examples", 401, "Unauthorized Access. Incorrect Credentials", [:username, :password]
+ it "returns 401" do
+ subject
+ expect(response.status).to eq(401)
end
+
+ it "sets message" do
+ subject
+ expect(response_body["message"]).to eq("Invalid token")
+ end
+ end
+ end
+ end
+
+ describe "POST: password_change_post" do
+ subject(:password_change_post) { post(:password_change_post, params:) }
+
+ let(:sentinel_current) { "HttpSentinelCurrent_Aa1!" }
+ let(:sentinel_new) { "HttpSentinelNew_Bb2!" }
+ let(:sentinel_wrong) { "HttpSentinelWrong_Cc3!" }
+
+ let(:user) { create(:user, password: sentinel_current, password_confirmation: sentinel_current) }
+ let(:current_password) { sentinel_current }
+ let(:password) { sentinel_new }
+ let(:password_confirmation) { password }
+ let(:params) do
+ {
+ current_password:,
+ password:,
+ password_confirmation:,
+ }.compact
+ end
+
+ def assert_response_no_secret_leak!
+ body = response.body
+ expect(body).not_to include(sentinel_current)
+ expect(body).not_to include(sentinel_new)
+ expect(body).not_to include(sentinel_wrong)
+ expect(body).not_to include(user.reload.verifier_token) if user.verifier_token.present?
+ expect(response_body).not_to have_key("token")
+ expect(response_body).not_to have_key("verifier_token")
+ end
+
+ include_examples "Invalid/Missing JWT token on required route"
+
+ context "with valid JWT and passwords" do
+ before { set_jwt_token!(user:) }
+
+ let!(:old_jwt) do
+ user.retreive_verifier_token!
+ CommandTower::Jwt::LoginCreate.(user: user.reload).token
+ end
+
+ it "returns 200" do
+ subject
+ expect(response.status).to eq(200)
+ assert_response_no_secret_leak!
+ end
+
+ it "sets message" do
+ subject
+ expect(response_body["message"]).to eq("Password has been successfully changed")
+ assert_response_no_secret_leak!
+ end
+
+ it "updates password and invalidates prior JWT" do
+ subject
+ expect(user.reload.authenticate(sentinel_new)).to be_truthy
+ expect(CommandTower::Jwt::AuthenticateUser.(token: old_jwt).failure?).to eq(true)
+ end
+ end
+
+ context "with incorrect current password" do
+ before { set_jwt_token!(user:) }
+
+ let(:current_password) { sentinel_wrong }
+
+ include_examples "CommandTower::Schema::Error:InvalidArguments examples", 400, "Incorrect current password", :current_password
+
+ it "does not leak secrets" do
+ subject
+ assert_response_no_secret_leak!
+ end
+ end
+
+ context "with password confirmation mismatch" do
+ before { set_jwt_token!(user:) }
+
+ let(:password_confirmation) { sentinel_wrong }
+
+ include_examples "CommandTower::Schema::Error:InvalidArguments examples", 400, "Password and confirmation do not match", :password_confirmation
+
+ it "does not leak secrets" do
+ subject
+ assert_response_no_secret_leak!
+ end
+ end
+
+ context "with password too short" do
+ before { set_jwt_token!(user:) }
+
+ let(:password) { "short" }
+ let(:password_confirmation) { "short" }
+
+ include_examples "CommandTower::Schema::Error:InvalidArguments examples", 400, "Password length must be between", :password
+
+ it "does not leak secrets" do
+ subject
+ assert_response_no_secret_leak!
end
end
end
diff --git a/spec/controllers/command_tower/inbox/message_blast_controller_spec.rb b/spec/controllers/command_tower/inbox/message_blast_controller_spec.rb
index 7752ddd..1fe4ad6 100644
--- a/spec/controllers/command_tower/inbox/message_blast_controller_spec.rb
+++ b/spec/controllers/command_tower/inbox/message_blast_controller_spec.rb
@@ -17,7 +17,7 @@
it "returns metadata values" do
metadata
- expect(response_body).to include(*CommandTower::Schema::Inbox::MessageBlastMetadata.introspect.keys)
+ expect(response_body).to include(*CommandTower::Schema::Shared::Inbox::MessageBlastMetadata.introspect.keys)
end
context "with auth* failures" do
@@ -43,7 +43,7 @@
it "returns entity values" do
blast
- expect(response_body).to include(*CommandTower::Schema::Inbox::MessageBlastEntity.introspect.keys)
+ expect(response_body).to include(*CommandTower::Schema::Entities::Inbox::MessageBlastEntity.introspect.keys)
expect(response_body["text"]).to eq(message_blast.text)
end
@@ -91,7 +91,7 @@
it "returns blast" do
create_post
- expect(response_body).to include(*CommandTower::Schema::Inbox::BlastResponse.introspect.keys)
+ expect(response_body).to include(*CommandTower::Schema::Inbox::Blast::Create::Response.introspect.keys)
expect(response_body["text"]).to eq(text)
end
@@ -140,7 +140,7 @@
it "returns blast" do
modify
- expect(response_body).to include(*CommandTower::Schema::Inbox::BlastResponse.introspect.keys)
+ expect(response_body).to include(*CommandTower::Schema::Inbox::Blast::Create::Response.introspect.keys)
end
it "modifies existing blast" do
@@ -184,4 +184,3 @@
end
end
end
-
diff --git a/spec/controllers/command_tower/inbox/message_controller_spec.rb b/spec/controllers/command_tower/inbox/message_controller_spec.rb
index 1059560..8779906 100644
--- a/spec/controllers/command_tower/inbox/message_controller_spec.rb
+++ b/spec/controllers/command_tower/inbox/message_controller_spec.rb
@@ -22,7 +22,19 @@
it "returns metadata values" do
metadata
- expect(response_body).to include(*CommandTower::Schema::Inbox::Metadata.introspect.keys)
+ expect(response_body).to include(*CommandTower::Schema::Shared::Inbox::Metadata.introspect.keys)
+ end
+
+ it "includes created_at in entities" do
+ metadata
+ entities = response_body["entities"]
+ expect(entities).to be_present
+ entities.each do |entity|
+ expect(entity).to have_key("created_at")
+ expect(entity["created_at"]).to be_a(String)
+ # Verify it's a valid ISO 8601 date string
+ expect { Time.iso8601(entity["created_at"]) }.not_to raise_error
+ end
end
context "with pagination" do
@@ -112,8 +124,13 @@
it "returns message values" do
message
- expect(response_body).to include(*CommandTower::Schema::Inbox::MessageEntity.introspect.keys)
+ expect(response_body).to include(*CommandTower::Schema::Entities::Inbox::MessageEntity.introspect.keys)
expect(response_body["text"]).to eq(record.text)
+ expect(response_body).to have_key("created_at")
+ expect(response_body["created_at"]).to be_a(String)
+ expect(response_body["created_at"]).to eq(record.created_at.iso8601)
+ # Verify it's a valid ISO 8601 date string
+ expect { Time.iso8601(response_body["created_at"]) }.not_to raise_error
end
context "with incorrect ID" do
@@ -135,7 +152,7 @@
it "changes metadata" do
subject
- expect(response_body).to include(*CommandTower::Schema::Inbox::Modified.introspect.keys)
+ expect(response_body).to include(*CommandTower::Schema::Shared::Inbox::Modified.introspect.keys)
expect(response_body["count"]).to eq(messages.length)
expect(response_body["type"]).to eq(modify_type)
expect(response_body["ids"]).to include(*ids)
@@ -161,7 +178,7 @@
it "changes metadata" do
subject
- expect(response_body).to include(*CommandTower::Schema::Inbox::Modified.introspect.keys)
+ expect(response_body).to include(*CommandTower::Schema::Shared::Inbox::Modified.introspect.keys)
expect(response_body["count"]).to eq(messages.length)
expect(response_body["type"]).to eq(modify_type)
expect(response_body["ids"]).to include(*available_ids)
diff --git a/spec/controllers/command_tower/user_controller_spec.rb b/spec/controllers/command_tower/user_controller_spec.rb
index 1495ccc..23607fb 100644
--- a/spec/controllers/command_tower/user_controller_spec.rb
+++ b/spec/controllers/command_tower/user_controller_spec.rb
@@ -19,7 +19,13 @@
it "returns user values" do
subject
- expect(response_body).to include(*CommandTower::Schema::User.introspect.keys)
+ expect(response_body).to include(*CommandTower::Schema::Shared::User.introspect.keys)
+ end
+
+ it "does not return verifier_token" do
+ subject
+
+ expect(response_body).to_not have_key("verifier_token")
end
include_examples "Invalid/Missing JWT token on required route"
@@ -58,7 +64,7 @@
it "returns new user keys" do
subject
- expect(response_body.keys).to include(*CommandTower::Schema::User.introspect.keys)
+ expect(response_body.keys).to include(*CommandTower::Schema::Shared::User.introspect.keys)
end
it "has correct key change" do
@@ -121,7 +127,47 @@
it "returns new user keys" do
subject
- expect(response_body.keys).to include(*CommandTower::Schema::User.introspect.keys)
+ expect(response_body.keys).to include(*CommandTower::Schema::Shared::User.introspect.keys)
+ end
+
+ it "does not return verifier_token in response" do
+ subject
+
+ expect(response_body).to_not have_key("verifier_token")
+ end
+
+ it "changes verifier_token in database" do
+ original_token = user.reload.verifier_token
+ subject
+
+ expect(user.reload.verifier_token).to_not eq(original_token)
+ expect(user.reload.verifier_token).to be_present
+ end
+
+ it "updates verifier_token_last_reset timestamp" do
+ original_timestamp = user.reload.verifier_token_last_reset
+ subject
+
+ new_timestamp = user.reload.verifier_token_last_reset
+ expect(new_timestamp).to be_present
+ if original_timestamp
+ expect(new_timestamp).to be >= original_timestamp
+ end
+ end
+
+ it "invalidates existing JWT tokens" do
+ # Get the current token before reset
+ old_token = @request.headers[CommandTower::ApplicationController::AUTHENTICATION_HEADER]
+
+ # Reset verifier_token
+ subject
+
+ # Try to use the old token - it should fail
+ @request.headers[CommandTower::ApplicationController::AUTHENTICATION_HEADER] = old_token
+ get :show
+
+ expect(response.status).to eq(401)
+ expect(JSON.parse(response.body)["message"]).to include("Unauthorized")
end
end
diff --git a/spec/examples.txt b/spec/examples.txt
index 0ab350b..a7ed2e8 100644
--- a/spec/examples.txt
+++ b/spec/examples.txt
@@ -1,1001 +1,1381 @@
example_id | status | run_time |
---------------------------------------------------------------------------------------------------- | ------- | --------------- |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:1:1:1] | passed | 0.01773 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:1:1:2] | passed | 0.04028 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:1:1:3] | passed | 0.01769 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:1:2:1:1] | passed | 0.03332 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:1:2:1:2] | passed | 0.02675 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:1:2:1:3] | passed | 0.02901 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:1:2:2:1] | passed | 0.02305 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:1:2:2:2] | passed | 0.018 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:1:2:2:3] | passed | 0.02199 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:2:1] | passed | 0.02922 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:2:2] | passed | 0.02448 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:1:2] | passed | 0.02016 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:1:3] | passed | 0.01861 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:1:4] | passed | 0.01857 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:1:1:1] | passed | 0.00715 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:1:1:2] | passed | 0.00778 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:1:1:3] | passed | 0.00712 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:1:2:1:1] | passed | 0.00958 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:1:2:1:2] | passed | 0.00922 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:1:2:1:3] | passed | 0.00994 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:1:2:2:1] | passed | 0.00831 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:1:2:2:2] | passed | 0.00813 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:1:2:2:3] | passed | 0.01162 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:2:1] | passed | 0.01019 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:2:2] | passed | 0.00919 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:1:1:1] | passed | 0.00971 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:1:1:2] | passed | 0.01 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:1:1:3] | passed | 0.01064 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:1:1:4] | passed | 0.00964 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:2:1:1] | passed | 0.01075 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:2:1:2] | passed | 0.01049 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:2:1:3] | passed | 0.01042 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:2:1:4] | passed | 0.01086 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:3:1:1:1] | passed | 0.0092 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:3:1:1:2] | passed | 0.00963 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:3:1:1:3] | passed | 0.00945 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:3:1:1:4] | passed | 0.00892 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:3:2:1:1] | passed | 0.01191 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:3:2:1:2] | passed | 0.01031 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:3:2:1:3] | passed | 0.00959 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:3:2:1:4] | passed | 0.00946 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:3:1] | passed | 0.01032 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:3:2] | passed | 0.01092 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:3:3] | passed | 0.00996 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:3:4:1] | passed | 0.00694 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:3:4:2] | passed | 0.01003 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:4:1] | passed | 0.01046 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:4:2] | passed | 0.01056 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:4:3] | passed | 0.01027 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:4:4:1] | passed | 0.01087 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:4:4:2] | passed | 0.00676 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:5:1] | passed | 0.01146 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:5:2] | passed | 0.01081 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:5:3:1] | passed | 0.00742 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:5:3:2] | passed | 0.00805 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:6:1] | passed | 0.01174 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:6:2] | passed | 0.01197 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:6:3] | passed | 0.0126 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:6:4:1] | passed | 0.00767 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:6:4:2] | passed | 0.00843 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:7:1] | passed | 0.01022 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:7:2] | passed | 0.01128 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:7:3] | passed | 0.01094 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:7:4:1] | passed | 0.00713 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:2:7:4:2] | passed | 0.00705 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:1:1:1] | passed | 0.00701 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:1:1:2] | passed | 0.00716 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:1:1:3] | passed | 0.00687 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:1:2:1:1] | passed | 0.00881 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:1:2:1:2] | passed | 0.00984 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:1:2:1:3] | passed | 0.00959 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:1:2:2:1] | passed | 0.0105 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:1:2:2:2] | passed | 0.00869 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:1:2:2:3] | passed | 0.00942 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:2:1] | passed | 0.00865 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:2:2] | passed | 0.00785 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:2:1:1] | passed | 0.00709 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:2:1:2] | passed | 0.00686 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:2:2:1] | passed | 0.00914 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:2:2:2] | passed | 0.00882 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:2:2:3] | passed | 0.01199 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:2:2:4] | passed | 0.00971 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:3] | passed | 0.0103 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:4] | passed | 0.01086 seconds |
-./spec/controllers/command_tower/admin_controller_spec.rb[1:3:5] | passed | 0.0103 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:1:1:1] | passed | 0.00192 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:1:1:2] | passed | 0.00193 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:1:1:3] | passed | 0.00183 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:1:1:4] | passed | 0.00217 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:2:1:1] | passed | 0.0025 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:2:1:2] | passed | 0.00195 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:2:1:3] | passed | 0.00207 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:2:1:4] | passed | 0.00249 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:3:1:1] | passed | 0.00453 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:3:1:2] | passed | 0.0047 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:3:1:3] | passed | 0.00605 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:3:1:4] | passed | 0.00498 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:1:1:1] | passed | 0.002 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:1:1:2] | passed | 0.00193 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:1:1:3] | passed | 0.00231 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:1:1:4] | passed | 0.00194 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:2:1:1] | passed | 0.00183 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:2:1:2] | passed | 0.00282 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:2:1:3] | passed | 0.00169 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:2:1:4] | passed | 0.00197 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:3:1:1] | passed | 0.00186 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:3:1:2] | passed | 0.00193 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:3:1:3] | passed | 0.00199 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:3:1:4] | passed | 0.00198 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:4:1:1] | passed | 0.00217 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:4:1:2] | passed | 0.00171 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:4:1:3] | passed | 0.00189 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:4:1:4] | passed | 0.00209 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:5:1:1] | passed | 0.00198 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:5:1:2] | passed | 0.00201 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:5:1:3] | passed | 0.00187 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:5:1:4] | passed | 0.00198 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:6:1:1] | passed | 0.00196 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:6:1:2] | passed | 0.00191 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:6:1:3] | passed | 0.00218 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:6:1:4] | passed | 0.0019 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:7:1:1] | passed | 0.00192 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:7:1:2] | passed | 0.00195 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:7:1:3] | passed | 0.00218 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:7:1:4] | passed | 0.00201 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:5] | passed | 0.0052 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:6] | passed | 0.00618 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:7] | passed | 0.0056 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:1:1:1] | passed | 0.00571 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:1:1:2] | passed | 0.00612 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:1:1:3] | passed | 0.00581 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:1:2:1:1] | passed | 0.00794 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:1:2:1:2] | passed | 0.00753 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:1:2:1:3] | passed | 0.00845 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:1:2:2:1] | passed | 0.01353 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:1:2:2:2] | passed | 0.01221 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:1:2:2:3] | passed | 0.00714 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:2:1:1] | passed | 0.006 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:2:1:2] | passed | 0.00658 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:2:1:3] | passed | 0.00679 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:2:1:4] | passed | 0.00657 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:2:2:1:1] | passed | 0.01666 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:2:2:1:2] | passed | 0.01104 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:2:2:1:3] | passed | 0.01109 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:2:2:1:4] | passed | 0.01204 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:3:1] | passed | 0.01262 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:3:2] | passed | 0.01198 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:4:1] | passed | 0.00659 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:4:2] | passed | 0.00683 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:1:1:1] | passed | 0.00607 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:1:1:2] | passed | 0.00526 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:1:1:3] | passed | 0.0055 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:1:2:1:1] | passed | 0.00583 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:1:2:1:2] | passed | 0.00554 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:1:2:1:3] | passed | 0.00557 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:1:2:2:1] | passed | 0.00578 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:1:2:2:2] | passed | 0.00583 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:1:2:2:3] | passed | 0.00576 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:2] | passed | 0.00963 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:3] | passed | 0.01089 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:4:1] | passed | 0.00889 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:4:2] | passed | 0.01069 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:5:1] | passed | 0.00534 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:5:2] | passed | 0.00583 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:1:1:1] | passed | 0.00667 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:1:1:2] | passed | 0.00677 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:1:2:1] | passed | 0.00652 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:1:2:2] | passed | 0.01108 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:1:1:1] | passed | 0.00391 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:1:1:2] | passed | 0.00393 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:1:1:3] | passed | 0.00525 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:1:1:4] | passed | 0.00403 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:2:1:1] | passed | 0.00155 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:2:1:2] | passed | 0.00167 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:2:1:3] | passed | 0.00174 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:2:1:4] | passed | 0.00148 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:3:1:1] | passed | 0.00384 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:3:1:2] | passed | 0.00406 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:3:1:3] | passed | 0.00413 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:3:1:4] | passed | 0.00407 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:3:1:1:1:1] | passed | 0.00206 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:3:1:1:1:2] | passed | 0.00218 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:3:1:1:1:3] | passed | 0.00266 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:3:1:1:1:4] | passed | 0.00204 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:3:1:2:1:1] | passed | 0.00221 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:3:1:2:1:2] | passed | 0.00241 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:3:1:2:1:3] | passed | 0.00276 seconds |
-./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:3:1:2:1:4] | passed | 0.00194 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:1] | passed | 0.01007 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:2] | passed | 0.00696 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:1:1:1] | passed | 0.00603 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:1:1:2] | passed | 0.00543 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:1:1:3] | passed | 0.00534 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:1:2:1:1] | passed | 0.00626 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:1:2:1:2] | passed | 0.00653 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:1:2:1:3] | passed | 0.0067 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:1:2:2:1] | passed | 0.00593 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:1:2:2:2] | passed | 0.00673 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:1:2:2:3] | passed | 0.00636 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:2:1] | passed | 0.00675 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:2:2] | passed | 0.00722 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:1] | passed | 0.0111 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:2] | passed | 0.01139 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:3:1:1] | passed | 0.00718 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:3:1:2] | passed | 0.00744 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:3:1:3] | passed | 0.00831 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:3:1:4] | passed | 0.00743 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:1:1:1] | passed | 0.00753 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:1:1:2] | passed | 0.00837 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:1:1:3] | passed | 0.00932 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:1:2:1:1] | passed | 0.00911 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:1:2:1:2] | passed | 0.00861 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:1:2:1:3] | passed | 0.00833 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:1:2:2:1] | passed | 0.00869 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:1:2:2:2] | passed | 0.00864 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:1:2:2:3] | passed | 0.00931 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:2:1] | passed | 0.00948 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:2:2] | passed | 0.00956 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:1] | passed | 0.00753 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:2] | passed | 0.0092 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:3] | passed | 0.01 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:4:1:1] | passed | 0.00699 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:4:1:2] | passed | 0.00731 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:4:1:3] | passed | 0.0098 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:4:1:4] | passed | 0.00748 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:1:1:1] | passed | 0.00577 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:1:1:2] | passed | 0.00555 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:1:1:3] | passed | 0.00552 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:1:2:1:1] | passed | 0.0062 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:1:2:1:2] | passed | 0.00617 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:1:2:1:3] | passed | 0.00622 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:1:2:2:1] | passed | 0.00632 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:1:2:2:2] | passed | 0.00772 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:1:2:2:3] | passed | 0.00935 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:2:1] | passed | 0.00753 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:2:2] | passed | 0.00717 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:1] | passed | 0.01482 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:2] | passed | 0.01194 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:3] | passed | 0.01304 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:4] | passed | 0.01237 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:5:1:1] | passed | 0.01154 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:5:1:2] | passed | 0.01047 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:5:1:3] | passed | 0.01051 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:5:1:4] | passed | 0.01105 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:1:1:1] | passed | 0.01329 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:1:1:2] | passed | 0.00908 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:1:1:3] | passed | 0.00825 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:1:2:1:1] | passed | 0.00934 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:1:2:1:2] | passed | 0.00894 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:1:2:1:3] | passed | 0.00945 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:1:2:2:1] | passed | 0.00943 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:1:2:2:2] | passed | 0.00939 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:1:2:2:3] | passed | 0.0125 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:2:1] | passed | 0.01095 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:2:2] | passed | 0.00979 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:5:1] | passed | 0.01442 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:5:2] | passed | 0.0138 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:5:3:1:1] | passed | 0.0116 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:5:3:1:2] | passed | 0.0115 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:5:3:1:3] | passed | 0.0127 seconds |
-./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:5:3:1:4] | passed | 0.01651 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:1] | passed | 0.01324 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:2] | passed | 0.01697 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:3:1:1] | passed | 0.01203 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:3:1:2] | passed | 0.01102 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:3:1:3] | passed | 0.00965 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:3:2:1:1] | passed | 0.01317 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:3:2:1:2] | passed | 0.01761 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:3:2:1:3] | passed | 0.01226 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:3:2:2:1] | passed | 0.01079 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:3:2:2:2] | passed | 0.01113 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:3:2:2:3] | passed | 0.0107 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:1] | passed | 0.01148 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:2] | passed | 0.01309 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:3:1:1] | passed | 0.02146 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:3:1:2] | passed | 0.01133 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:3:1:3] | passed | 0.01232 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:3:1:4] | passed | 0.01156 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:4:1:1] | passed | 0.01087 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:4:1:2] | passed | 0.01153 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:4:1:3] | passed | 0.01068 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:4:2:1:1] | passed | 0.01939 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:4:2:1:2] | passed | 0.013 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:4:2:1:3] | passed | 0.01254 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:4:2:2:1] | passed | 0.01438 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:4:2:2:2] | passed | 0.0106 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:4:2:2:3] | passed | 0.01152 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:1] | passed | 0.0727 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:2] | passed | 0.04588 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:3:1:1] | passed | 0.0243 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:3:1:2] | passed | 0.01607 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:3:1:3] | passed | 0.01727 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:3:1:4] | passed | 0.01582 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:4:1] | passed | 0.01833 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:4:2] | passed | 0.02393 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:5:1:1] | passed | 0.02019 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:5:1:2] | passed | 0.01537 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:5:1:3] | passed | 0.01238 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:5:2:1:1] | passed | 0.01402 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:5:2:1:2] | passed | 0.01802 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:5:2:1:3] | passed | 0.01547 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:5:2:2:1] | passed | 0.01725 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:5:2:2:2] | passed | 0.01559 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:5:2:2:3] | passed | 0.02293 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:1] | passed | 0.01525 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:2] | passed | 0.02277 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:3:1:1] | passed | 0.02236 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:3:1:2] | passed | 0.01188 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:3:1:3] | passed | 0.01442 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:3:1:4] | passed | 0.01287 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:4:1] | passed | 0.02637 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:4:2] | passed | 0.01585 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:5:1:1] | passed | 0.01236 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:5:1:2] | passed | 0.01203 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:5:1:3] | passed | 0.01759 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:5:2:1:1] | passed | 0.01134 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:5:2:1:2] | passed | 0.01194 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:5:2:1:3] | passed | 0.01139 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:5:2:2:1] | passed | 0.0152 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:5:2:2:2] | passed | 0.01535 seconds |
-./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:5:2:2:3] | passed | 0.0149 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:1:1] | passed | 0.00769 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:1:2] | passed | 0.01177 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:1:3:1:1] | passed | 0.00589 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:1:3:1:2] | passed | 0.00664 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:1:3:1:3] | passed | 0.00645 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:1:3:2:1:1] | passed | 0.00699 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:1:3:2:1:2] | passed | 0.00637 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:1:3:2:1:3] | passed | 0.00637 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:1:3:2:2:1] | passed | 0.00784 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:1:3:2:2:2] | passed | 0.00695 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:1:3:2:2:3] | passed | 0.00627 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:1:1:1] | passed | 0.00699 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:1:1:2] | passed | 0.01057 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:1:1:3] | passed | 0.00724 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:1:1:4] | passed | 0.0074 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:2:1:1] | passed | 0.02549 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:2:1:2] | passed | 0.00714 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:2:1:3] | passed | 0.00782 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:2:1:4] | passed | 0.01004 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:3:1:1:1] | passed | 0.00759 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:3:1:1:2] | passed | 0.0079 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:3:1:1:3] | passed | 0.00772 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:3:1:1:4] | passed | 0.01005 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:3:2:1:1] | passed | 0.00824 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:3:2:1:2] | passed | 0.0088 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:3:2:1:3] | passed | 0.01662 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:3:2:1:4] | passed | 0.01079 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:2:1] | passed | 0.014 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:2:2] | passed | 0.01321 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:2:3] | passed | 0.01107 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:3:1] | passed | 0.0113 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:3:2] | passed | 0.01044 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:3:3] | passed | 0.0107 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:4:1] | passed | 0.00926 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:4:2] | passed | 0.00879 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:5:1] | passed | 0.00763 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:5:2] | passed | 0.00897 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:5:3] | passed | 0.00875 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:6:1] | passed | 0.01325 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:6:2] | passed | 0.01392 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:6:3] | passed | 0.01526 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:7:1:1] | passed | 0.00858 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:7:1:2] | passed | 0.00552 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:7:1:3] | passed | 0.00878 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:7:2:1:1] | passed | 0.00694 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:7:2:1:2] | passed | 0.00614 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:7:2:1:3] | passed | 0.00849 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:7:2:2:1] | passed | 0.00702 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:7:2:2:2] | passed | 0.00789 seconds |
-./spec/controllers/command_tower/user_controller_spec.rb[1:2:7:2:2:3] | passed | 0.0085 seconds |
-./spec/integration_test/admin/change_settings_spec.rb[1:1] | passed | 0.06818 seconds |
-./spec/integration_test/application_controller_actions/header_with_reset_spec.rb[1:1] | passed | 0.02506 seconds |
-./spec/integration_test/auth/plain_text/integration_spec.rb[1:1] | passed | 0.09429 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:1:1] | passed | 0.00067 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:1:2:1] | passed | 0.00083 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:2:1] | passed | 0.00092 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:3:1:1] | passed | 0.00076 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:3:2:1] | passed | 0.0009 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:3:3:1:1] | passed | 0.00077 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:3:3:2:1] | passed | 0.00087 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:3:4:1] | passed | 0.00078 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:3:4:2:1] | passed | 0.00073 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:3:5:1] | passed | 0.00067 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:3:5:2:1] | passed | 0.00098 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:3:6] | passed | 0.0007 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:4:1] | passed | 0.00061 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:5:1] | passed | 0.00338 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:6:1:1:1] | passed | 0.00065 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:6:1:2:1:1] | passed | 0.00066 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:6:1:2:2:1] | passed | 0.00068 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:6:1:3:1:1] | passed | 0.00098 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:6:1:3:2:1] | passed | 0.00061 seconds |
-./spec/lib/command_tower/authorization/entity_spec.rb[1:6:2:1] | passed | 0.00055 seconds |
-./spec/lib/command_tower/authorization/role_spec.rb[1:1:1:1] | passed | 0.00089 seconds |
-./spec/lib/command_tower/authorization/role_spec.rb[1:1:2:1] | passed | 0.00105 seconds |
-./spec/lib/command_tower/authorization/role_spec.rb[1:1:3:1] | passed | 0.00068 seconds |
-./spec/lib/command_tower/authorization/role_spec.rb[1:1:4:1] | passed | 0.00119 seconds |
-./spec/lib/command_tower/authorization/role_spec.rb[1:1:5:1] | passed | 0.00141 seconds |
-./spec/lib/command_tower/authorization/role_spec.rb[1:1:6] | passed | 0.00149 seconds |
-./spec/lib/command_tower/authorization/role_spec.rb[1:2:1:1] | passed | 0.00313 seconds |
-./spec/lib/command_tower/authorization/role_spec.rb[1:2:2:1] | passed | 0.00316 seconds |
-./spec/lib/command_tower/authorization/role_spec.rb[1:2:3:1] | passed | 0.00319 seconds |
-./spec/lib/command_tower/authorization/role_spec.rb[1:2:4:1] | passed | 0.00321 seconds |
-./spec/lib/command_tower/authorization/role_spec.rb[1:2:4:2:1] | passed | 0.00284 seconds |
-./spec/lib/command_tower/authorization/role_spec.rb[1:2:4:3:1] | passed | 0.00338 seconds |
-./spec/lib/command_tower/authorization/role_spec.rb[1:2:4:3:2:1] | passed | 0.00358 seconds |
-./spec/lib/command_tower/authorization/role_spec.rb[1:3:1:1] | passed | 0.00098 seconds |
-./spec/lib/command_tower/authorization/role_spec.rb[1:3:2:1] | pending | 0.00065 seconds |
-./spec/lib/command_tower/authorization/role_spec.rb[1:3:2:2] | passed | 0.00139 seconds |
-./spec/lib/command_tower/authorization/role_spec.rb[1:3:3:1] | passed | 0.00103 seconds |
-./spec/lib/command_tower/authorization_spec.rb[1:1:1] | passed | 0.00111 seconds |
-./spec/lib/command_tower/authorization_spec.rb[1:1:2] | passed | 0.00124 seconds |
-./spec/lib/command_tower/authorization_spec.rb[1:1:3] | passed | 0.00208 seconds |
-./spec/lib/command_tower/authorization_spec.rb[1:1:4] | passed | 0.013 seconds |
-./spec/lib/command_tower/authorization_spec.rb[1:2:1:1] | passed | 0.00195 seconds |
-./spec/lib/command_tower/authorization_spec.rb[1:2:2:1] | passed | 0.00418 seconds |
-./spec/lib/command_tower/authorization_spec.rb[1:2:3:1] | passed | 0.00149 seconds |
-./spec/models/user_secret_spec.rb[1:1:1] | passed | 0.00553 seconds |
-./spec/models/user_secret_spec.rb[1:1:2] | passed | 0.00735 seconds |
-./spec/models/user_secret_spec.rb[1:1:3:1] | passed | 0.00584 seconds |
-./spec/models/user_secret_spec.rb[1:1:3:2] | passed | 0.00567 seconds |
-./spec/models/user_secret_spec.rb[1:1:4:1] | passed | 0.005 seconds |
-./spec/models/user_secret_spec.rb[1:1:4:2] | passed | 0.00531 seconds |
-./spec/models/user_secret_spec.rb[1:1:5:1] | passed | 0.00477 seconds |
-./spec/models/user_secret_spec.rb[1:1:6:1] | passed | 0.00378 seconds |
-./spec/models/user_secret_spec.rb[1:2:1:1] | passed | 0.00364 seconds |
-./spec/models/user_secret_spec.rb[1:2:2:1] | passed | 0.00365 seconds |
-./spec/models/user_secret_spec.rb[1:2:3:1] | passed | 0.00361 seconds |
-./spec/models/user_secret_spec.rb[1:2:4:1] | passed | 0.00376 seconds |
-./spec/models/user_secret_spec.rb[1:3:1] | passed | 0.01983 seconds |
-./spec/models/user_secret_spec.rb[1:4:1] | passed | 0.00382 seconds |
-./spec/models/user_secret_spec.rb[1:4:2:1] | passed | 0.00361 seconds |
-./spec/models/user_secret_spec.rb[1:4:3:1] | passed | 0.00363 seconds |
-./spec/models/user_secret_spec.rb[1:5:1] | passed | 0.00465 seconds |
-./spec/models/user_secret_spec.rb[1:5:2:1] | passed | 0.00391 seconds |
-./spec/models/user_secret_spec.rb[1:5:3:1] | passed | 0.00371 seconds |
-./spec/models/user_secret_spec.rb[1:6:1] | passed | 0.00379 seconds |
-./spec/models/user_secret_spec.rb[1:6:2:1] | passed | 0.00371 seconds |
-./spec/models/user_secret_spec.rb[1:6:3:1] | passed | 0.00337 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:1:1:1] | passed | 0.03453 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:1:1:2] | passed | 0.03878 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:1:1:3] | passed | 0.04387 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:1:2:1:1] | passed | 0.27361 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:1:2:1:2] | passed | 0.05836 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:1:2:1:3] | passed | 0.03898 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:1:2:2:1] | passed | 0.05998 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:1:2:2:2] | passed | 0.04163 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:1:2:2:3] | passed | 0.04006 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:2:1] | passed | 0.04486 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:1:2:2] | passed | 0.02981 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:2] | passed | 0.03619 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:3] | passed | 0.02923 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:4] | passed | 0.0462 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:5] | passed | 0.04094 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:6:1:1] | passed | 0.0959 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:6:2:1:1] | passed | 0.09646 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:6:2:2:1] | passed | 0.09436 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:6:2:3:1] | passed | 0.09592 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:6:3:1:1] | passed | 0.09076 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:6:3:2:1] | passed | 0.09279 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:1:6:3:3:1] | passed | 0.10133 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:1:1:1] | passed | 0.01264 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:1:1:2] | passed | 0.01052 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:1:1:3] | passed | 0.01124 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:1:2:1:1] | passed | 0.01309 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:1:2:1:2] | passed | 0.01482 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:1:2:1:3] | passed | 0.01473 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:1:2:2:1] | passed | 0.01494 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:1:2:2:2] | passed | 0.0191 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:1:2:2:3] | passed | 0.01318 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:2:1] | passed | 0.01212 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:1:2:2] | passed | 0.01317 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:1:1:1] | passed | 0.01287 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:1:1:2] | passed | 0.01456 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:1:1:3] | passed | 0.01381 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:1:1:4] | passed | 0.01272 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:2:1:1] | passed | 0.01768 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:2:1:2] | passed | 0.01461 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:2:1:3] | passed | 0.01467 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:2:1:4] | passed | 0.01355 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:3:1:1:1] | passed | 0.01391 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:3:1:1:2] | passed | 0.01322 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:3:1:1:3] | passed | 0.01342 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:3:1:1:4] | passed | 0.01301 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:3:2:1:1] | passed | 0.01424 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:3:2:1:2] | passed | 0.01566 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:3:2:1:3] | passed | 0.01394 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:2:3:2:1:4] | passed | 0.01382 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:3:1] | passed | 0.01665 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:3:2] | passed | 0.01556 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:3:3] | passed | 0.01448 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:3:4:1] | passed | 0.01018 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:3:4:2] | passed | 0.01074 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:4:1] | passed | 0.01837 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:4:2] | passed | 0.01562 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:4:3] | passed | 0.0152 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:4:4:1] | passed | 0.01072 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:4:4:2] | passed | 0.01124 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:5:1] | passed | 0.01987 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:5:2] | passed | 0.01717 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:5:3] | passed | 0.01816 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:5:4] | passed | 0.02731 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:5:5] | passed | 0.02176 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:5:6] | passed | 0.02283 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:5:7:1] | passed | 0.0126 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:5:7:2] | passed | 0.01529 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:6:1] | passed | 0.01817 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:6:2] | passed | 0.02465 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:6:3] | passed | 0.02382 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:6:4:1] | passed | 0.023 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:6:4:2] | passed | 0.01634 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:7:1] | passed | 0.01452 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:7:2] | passed | 0.01613 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:7:3] | passed | 0.01857 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:7:4:1] | passed | 0.01313 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:2:7:4:2] | passed | 0.0109 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:1:1:1] | passed | 0.01149 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:1:1:2] | passed | 0.01178 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:1:1:3] | passed | 0.01252 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:1:2:1:1] | passed | 0.07717 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:1:2:1:2] | passed | 0.0182 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:1:2:1:3] | passed | 0.01578 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:1:2:2:1] | passed | 0.01314 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:1:2:2:2] | passed | 0.01234 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:1:2:2:3] | passed | 0.01409 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:2:1] | passed | 0.01433 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:1:2:2] | passed | 0.01321 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:2:1:1] | passed | 0.01122 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:2:1:2] | passed | 0.01073 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:2:2:1] | passed | 0.01479 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:2:2:2] | passed | 0.0151 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:2:2:3] | passed | 0.01881 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:2:2:4] | passed | 0.01525 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:3] | passed | 0.02965 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:4] | passed | 0.02713 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:5] | passed | 0.02846 seconds |
+./spec/controllers/command_tower/admin_controller_spec.rb[1:3:6] | passed | 0.02331 seconds |
+./spec/controllers/command_tower/application_controller_spec.rb[1:1:1:1] | passed | 0.00185 seconds |
+./spec/controllers/command_tower/application_controller_spec.rb[1:1:1:2] | passed | 0.00143 seconds |
+./spec/controllers/command_tower/application_controller_spec.rb[1:1:2:1] | passed | 0.00179 seconds |
+./spec/controllers/command_tower/application_controller_spec.rb[1:1:2:2] | passed | 0.00157 seconds |
+./spec/controllers/command_tower/application_controller_spec.rb[1:1:3:1] | passed | 0.00255 seconds |
+./spec/controllers/command_tower/application_controller_spec.rb[1:1:3:2] | passed | 0.00161 seconds |
+./spec/controllers/command_tower/application_controller_spec.rb[1:1:4:1] | passed | 0.00145 seconds |
+./spec/controllers/command_tower/application_controller_spec.rb[1:1:4:2] | passed | 0.0019 seconds |
+./spec/controllers/command_tower/application_controller_spec.rb[1:1:5:1] | passed | 0.00184 seconds |
+./spec/controllers/command_tower/application_controller_spec.rb[1:1:5:2] | passed | 0.00169 seconds |
+./spec/controllers/command_tower/application_controller_spec.rb[1:1:6:1] | passed | 0.00298 seconds |
+./spec/controllers/command_tower/application_controller_spec.rb[1:1:6:2] | passed | 0.00134 seconds |
+./spec/controllers/command_tower/application_controller_spec.rb[1:1:7:1] | passed | 0.00946 seconds |
+./spec/controllers/command_tower/application_controller_spec.rb[1:1:8:1] | passed | 0.00928 seconds |
+./spec/controllers/command_tower/application_controller_spec.rb[1:1:9:1] | passed | 0.00865 seconds |
+./spec/controllers/command_tower/application_controller_spec.rb[1:1:10:1] | passed | 0.00178 seconds |
+./spec/controllers/command_tower/application_controller_spec.rb[1:1:10:2] | passed | 0.00143 seconds |
+./spec/controllers/command_tower/application_controller_spec.rb[1:1:11:1] | passed | 0.00159 seconds |
+./spec/controllers/command_tower/application_controller_spec.rb[1:1:11:2] | passed | 0.0014 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:1:1:1] | passed | 0.00272 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:1:1:2] | passed | 0.0025 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:1:1:3] | passed | 0.00261 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:1:1:4] | passed | 0.00249 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:2:1:1] | passed | 0.00296 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:2:1:2] | passed | 0.00268 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:2:1:3] | passed | 0.00271 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:2:1:4] | passed | 0.00253 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:3:1:1] | passed | 0.00645 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:3:1:2] | passed | 0.00637 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:3:1:3] | passed | 0.00662 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:3:1:4] | passed | 0.0067 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:1:1:1] | passed | 0.00222 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:1:1:2] | passed | 0.00278 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:1:1:3] | passed | 0.00221 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:1:1:4] | passed | 0.00355 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:2:1:1] | passed | 0.00398 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:2:1:2] | passed | 0.00194 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:2:1:3] | passed | 0.00247 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:2:1:4] | passed | 0.00213 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:3:1:1] | passed | 0.00221 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:3:1:2] | passed | 0.00206 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:3:1:3] | passed | 0.00253 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:3:1:4] | passed | 0.00233 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:4:1:1] | passed | 0.00231 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:4:1:2] | passed | 0.0024 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:4:1:3] | passed | 0.00208 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:4:1:4] | passed | 0.00216 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:5:1:1] | passed | 0.00208 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:5:1:2] | passed | 0.00224 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:5:1:3] | passed | 0.00225 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:5:1:4] | passed | 0.00205 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:6:1:1] | passed | 0.00205 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:6:1:2] | passed | 0.00215 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:6:1:3] | passed | 0.0026 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:6:1:4] | passed | 0.00244 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:7:1:1] | passed | 0.00275 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:7:1:2] | passed | 0.00224 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:7:1:3] | passed | 0.003 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:4:7:1:4] | passed | 0.00233 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:5] | passed | 0.00785 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:6] | passed | 0.01076 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:1:7] | passed | 0.00796 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:1:1:1] | passed | 0.0465 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:1:1:2] | passed | 0.01249 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:1:1:3] | passed | 0.06211 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:1:2:1:1] | passed | 0.01458 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:1:2:1:2] | passed | 0.01637 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:1:2:1:3] | passed | 0.02767 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:1:2:2:1] | passed | 0.01154 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:1:2:2:2] | passed | 0.01411 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:1:2:2:3] | passed | 0.01349 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:2:1:1] | passed | 0.01009 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:2:1:2] | passed | 0.01166 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:2:1:3] | passed | 0.01152 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:2:1:4] | passed | 0.01123 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:2:2:1:1] | passed | 0.01761 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:2:2:1:2] | passed | 0.01795 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:2:2:1:3] | passed | 0.01921 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:2:2:1:4] | passed | 0.01781 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:3:1] | passed | 0.01461 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:3:2] | passed | 0.01723 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:4:1] | passed | 0.01237 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:2:4:2] | passed | 0.01338 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:1:1:1] | passed | 0.01033 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:1:1:2] | passed | 0.00854 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:1:1:3] | passed | 0.0089 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:1:2:1:1] | passed | 0.01057 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:1:2:1:2] | passed | 0.00996 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:1:2:1:3] | passed | 0.00947 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:1:2:2:1] | passed | 0.01 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:1:2:2:2] | passed | 0.01217 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:1:2:2:3] | passed | 0.01105 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:2] | passed | 0.01762 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:3] | passed | 0.01427 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:4:1] | passed | 0.01901 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:4:2] | passed | 0.01291 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:5:1] | passed | 0.00923 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:3:5:2] | passed | 0.00987 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:1:1:1] | passed | 0.01314 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:1:1:2] | passed | 0.01281 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:1:1:3] | passed | 0.01509 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:1:2:1] | passed | 0.01332 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:1:2:2] | passed | 0.01422 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:1:2:3] | passed | 0.01397 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:1:1:1] | passed | 0.00232 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:1:1:2] | passed | 0.00303 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:1:1:3] | passed | 0.00267 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:1:1:4] | passed | 0.00273 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:2:1:1] | passed | 0.01729 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:2:1:2] | passed | 0.01159 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:2:1:3] | passed | 0.00904 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:2:2:1:4] | passed | 0.0087 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:3:1:1:1] | passed | 0.00511 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:3:1:1:2] | passed | 0.00362 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:3:1:1:3] | passed | 0.00404 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:4:3:1:1:4] | passed | 0.00369 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:5:1:1] | passed | 0.01402 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:5:1:2] | passed | 0.01345 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:5:1:3] | passed | 0.01331 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:5:2:1] | passed | 0.00665 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:5:2:2] | passed | 0.00377 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:5:2:3] | passed | 0.00405 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:5:3:1:1] | passed | 0.00193 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:5:3:1:2] | passed | 0.00255 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:5:3:1:3] | passed | 0.00263 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:5:3:1:4] | passed | 0.00317 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:5:4:1:1] | passed | 0.00375 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:5:4:1:2] | passed | 0.00415 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:5:4:1:3] | passed | 0.00425 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:5:4:1:4] | passed | 0.00484 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:5:5:1] | passed | 0.0115 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:5:5:2] | passed | 0.0119 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:1:1] | passed | 0.03002 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:1:2] | passed | 0.01222 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:1:3] | passed | 0.06911 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:2:1:1] | passed | 0.0104 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:2:1:2] | passed | 0.01218 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:2:2:1] | passed | 0.01021 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:2:2:2] | passed | 0.01406 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:2:3:1] | passed | 0.01078 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:2:3:2] | passed | 0.01141 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:3:1:1] | passed | 0.00957 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:3:1:2] | passed | 0.00873 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:3:2:1] | passed | 0.01061 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:3:2:2] | passed | 0.01013 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:3:3:1] | passed | 0.00987 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:3:3:2] | passed | 0.01163 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:4:1] | passed | 0.00352 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:4:2] | passed | 0.00386 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:5:1] | passed | 0.02114 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:5:2] | passed | 0.0137 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:6:1:1] | passed | 0.00554 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:6:1:2] | passed | 0.0047 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:6:1:3] | passed | 0.00464 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:6:6:1:4] | passed | 0.00546 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:1:1] | passed | 0.01451 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:1:2] | passed | 0.02168 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:1:3] | passed | 0.0326 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:2:1:1] | passed | 0.009 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:2:1:2] | passed | 0.00815 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:2:1:3] | passed | 0.00679 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:2:1:4] | passed | 0.00771 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:3:1] | passed | 0.00349 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:3:2] | passed | 0.0049 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:4:1] | passed | 0.01174 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:4:2] | passed | 0.01105 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:5:1:1] | passed | 0.00307 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:5:1:2] | passed | 0.00245 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:5:1:3] | passed | 0.00231 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:5:1:4] | passed | 0.00286 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:6:1:1] | passed | 0.00663 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:6:1:2] | passed | 0.00906 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:6:1:3] | passed | 0.00925 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:6:1:4] | passed | 0.00752 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:7:1:1] | passed | 0.01751 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:7:1:2] | passed | 0.04615 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:7:1:3] | passed | 0.04467 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:7:2:1] | passed | 0.03244 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:7:2:2] | passed | 0.07379 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:7:3:1] | passed | 0.01922 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:7:3:2] | passed | 0.01785 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:8:1:1] | passed | 0.00869 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:8:1:2] | passed | 0.01465 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:8:2:1] | passed | 0.01708 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:8:2:2] | passed | 0.06247 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:8:3:1] | passed | 0.01422 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:7:8:3:2] | passed | 0.01401 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:1:1:1] | passed | 0.00175 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:1:1:2] | passed | 0.00275 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:1:1:3] | passed | 0.00171 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:1:2:1:1] | passed | 0.00938 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:1:2:1:2] | passed | 0.00897 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:1:2:1:3] | passed | 0.00937 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:1:2:2:1] | passed | 0.00957 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:1:2:2:2] | passed | 0.00777 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:1:2:2:3] | passed | 0.00814 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:2:1] | passed | 0.01905 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:2:2] | passed | 0.01803 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:2:3] | passed | 0.02169 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:3:1:1] | passed | 0.0349 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:3:1:2] | passed | 0.01098 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:3:1:3] | passed | 0.01307 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:3:1:4] | passed | 0.01635 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:3:2] | passed | 0.01502 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:4:1:1] | passed | 0.01159 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:4:1:2] | passed | 0.01151 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:4:1:3] | passed | 0.01196 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:4:1:4] | passed | 0.01346 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:4:2] | passed | 0.01304 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:5:1:1] | passed | 0.01378 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:5:1:2] | passed | 0.01794 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:5:1:3] | passed | 0.01471 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:5:1:4] | passed | 0.01146 seconds |
+./spec/controllers/command_tower/auth/plain_text_controller_spec.rb[1:8:5:2] | passed | 0.01296 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:1] | passed | 0.01327 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:2] | passed | 0.01244 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:1:1:1] | passed | 0.00986 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:1:1:2] | passed | 0.01191 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:1:1:3] | passed | 0.01225 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:1:2:1:1] | passed | 0.01214 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:1:2:1:2] | passed | 0.01228 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:1:2:1:3] | passed | 0.01047 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:1:2:2:1] | passed | 0.01205 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:1:2:2:2] | passed | 0.01198 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:1:2:2:3] | passed | 0.01134 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:2:1] | passed | 0.01247 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:1:3:2:2] | passed | 0.01263 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:1] | passed | 0.02354 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:2] | passed | 0.01835 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:3:1:1] | passed | 0.01231 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:3:1:2] | passed | 0.01614 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:3:1:3] | passed | 0.0122 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:3:1:4] | passed | 0.01294 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:1:1:1] | passed | 0.01561 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:1:1:2] | passed | 0.01583 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:1:1:3] | passed | 0.01604 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:1:2:1:1] | passed | 0.01514 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:1:2:1:2] | passed | 0.01364 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:1:2:1:3] | passed | 0.01536 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:1:2:2:1] | passed | 0.01641 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:1:2:2:2] | passed | 0.01402 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:1:2:2:3] | passed | 0.01739 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:2:1] | passed | 0.01822 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:2:4:2:2] | passed | 0.01782 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:1] | passed | 0.0162 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:2] | passed | 0.0177 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:3] | passed | 0.01118 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:4:1:1] | passed | 0.01148 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:4:1:2] | passed | 0.01131 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:4:1:3] | passed | 0.0154 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:4:1:4] | passed | 0.021 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:1:1:1] | passed | 0.00826 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:1:1:2] | passed | 0.00747 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:1:1:3] | passed | 0.00855 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:1:2:1:1] | passed | 0.00959 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:1:2:1:2] | passed | 0.00876 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:1:2:1:3] | passed | 0.01003 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:1:2:2:1] | passed | 0.01093 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:1:2:2:2] | passed | 0.00977 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:1:2:2:3] | passed | 0.01029 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:2:1] | passed | 0.01032 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:3:5:2:2] | passed | 0.00936 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:1] | passed | 0.02415 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:2] | passed | 0.02191 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:3] | passed | 0.0212 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:4] | passed | 0.02096 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:5:1:1] | passed | 0.01558 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:5:1:2] | passed | 0.01572 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:5:1:3] | passed | 0.01625 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:5:1:4] | passed | 0.01828 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:1:1:1] | passed | 0.01494 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:1:1:2] | passed | 0.01657 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:1:1:3] | passed | 0.01533 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:1:2:1:1] | passed | 0.02072 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:1:2:1:2] | passed | 0.01628 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:1:2:1:3] | passed | 0.01649 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:1:2:2:1] | passed | 0.01584 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:1:2:2:2] | passed | 0.01404 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:1:2:2:3] | passed | 0.01815 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:2:1] | passed | 0.01558 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:4:6:2:2] | passed | 0.01521 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:5:1] | passed | 0.04865 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:5:2] | passed | 0.02983 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:5:3:1:1] | passed | 0.01992 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:5:3:1:2] | passed | 0.02124 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:5:3:1:3] | passed | 0.02566 seconds |
+./spec/controllers/command_tower/inbox/message_blast_controller_spec.rb[1:5:3:1:4] | passed | 0.01749 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:1] | passed | 0.01681 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:2] | passed | 0.02023 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:3] | passed | 0.01791 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:4:1:1] | passed | 0.05063 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:4:2:1:1] | passed | 0.06289 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:4:2:2:1] | passed | 0.06335 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:4:2:3:1] | passed | 0.05421 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:4:3:1:1] | passed | 0.04992 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:4:3:2:1] | passed | 0.05229 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:4:3:3:1] | passed | 0.05044 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:5:1:1] | passed | 0.01645 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:5:1:2] | passed | 0.01603 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:5:1:3] | passed | 0.01432 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:5:2:1:1] | passed | 0.01541 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:5:2:1:2] | passed | 0.01512 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:5:2:1:3] | passed | 0.01735 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:5:2:2:1] | passed | 0.01708 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:5:2:2:2] | passed | 0.01574 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:1:5:2:2:3] | passed | 0.01696 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:1] | passed | 0.02128 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:2] | passed | 0.01728 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:3:1:1] | passed | 0.01988 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:3:1:2] | passed | 0.02178 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:3:1:3] | passed | 0.02434 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:3:1:4] | passed | 0.02412 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:4:1:1] | passed | 0.01519 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:4:1:2] | passed | 0.01514 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:4:1:3] | passed | 0.01347 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:4:2:1:1] | passed | 0.01554 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:4:2:1:2] | passed | 0.01681 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:4:2:1:3] | passed | 0.01626 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:4:2:2:1] | passed | 0.01593 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:4:2:2:2] | passed | 0.0153 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:2:4:2:2:3] | passed | 0.01651 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:1] | passed | 0.02491 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:2] | passed | 0.04968 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:3:1:1] | passed | 0.04509 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:3:1:2] | passed | 0.03095 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:3:1:3] | passed | 0.02724 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:3:1:4] | passed | 0.07259 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:4:1] | passed | 0.02071 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:4:2] | passed | 0.02475 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:5:1:1] | passed | 0.01569 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:5:1:2] | passed | 0.01427 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:5:1:3] | passed | 0.01511 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:5:2:1:1] | passed | 0.01647 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:5:2:1:2] | passed | 0.01653 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:5:2:1:3] | passed | 0.01849 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:5:2:2:1] | passed | 0.01775 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:5:2:2:2] | passed | 0.01724 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:3:5:2:2:3] | passed | 0.01648 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:1] | passed | 0.02094 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:2] | passed | 0.02276 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:3:1:1] | passed | 0.01391 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:3:1:2] | passed | 0.01545 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:3:1:3] | passed | 0.01773 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:3:1:4] | passed | 0.01938 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:4:1] | passed | 0.02442 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:4:2] | passed | 0.02247 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:5:1:1] | passed | 0.0149 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:5:1:2] | passed | 0.01689 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:5:1:3] | passed | 0.0156 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:5:2:1:1] | passed | 0.01592 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:5:2:1:2] | passed | 0.01527 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:5:2:1:3] | passed | 0.016 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:5:2:2:1] | passed | 0.01797 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:5:2:2:2] | passed | 0.015 seconds |
+./spec/controllers/command_tower/inbox/message_controller_spec.rb[1:4:5:2:2:3] | passed | 0.018 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:1:1] | passed | 0.01162 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:1:2] | passed | 0.01101 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:1:3] | passed | 0.0159 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:1:4:1:1] | passed | 0.01133 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:1:4:1:2] | passed | 0.01003 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:1:4:1:3] | passed | 0.0116 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:1:4:2:1:1] | passed | 0.0131 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:1:4:2:1:2] | passed | 0.01493 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:1:4:2:1:3] | passed | 0.01353 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:1:4:2:2:1] | passed | 0.01906 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:1:4:2:2:2] | passed | 0.01588 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:1:4:2:2:3] | passed | 0.01754 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:1:1:1] | passed | 0.01224 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:1:1:2] | passed | 0.01353 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:1:1:3] | passed | 0.01244 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:1:1:4] | passed | 0.01241 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:2:1:1] | passed | 0.01236 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:2:1:2] | passed | 0.01075 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:2:1:3] | passed | 0.01069 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:2:1:4] | passed | 0.01309 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:3:1:1:1] | passed | 0.01337 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:3:1:1:2] | passed | 0.01302 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:3:1:1:3] | passed | 0.01122 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:3:1:1:4] | passed | 0.01101 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:3:2:1:1] | passed | 0.01146 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:3:2:1:2] | passed | 0.01259 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:3:2:1:3] | passed | 0.01187 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:1:3:2:1:4] | passed | 0.01558 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:2:1] | passed | 0.01676 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:2:2] | passed | 0.01577 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:2:3] | passed | 0.01734 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:3:1] | passed | 0.01625 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:3:2] | passed | 0.01426 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:3:3] | passed | 0.01428 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:4:1] | passed | 0.01365 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:4:2] | passed | 0.01469 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:4:3] | passed | 0.01538 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:4:4] | passed | 0.01539 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:4:5] | passed | 0.01485 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:4:6] | passed | 0.01942 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:5:1] | passed | 0.01609 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:5:2] | passed | 0.01521 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:5:3] | passed | 0.01549 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:6:1] | passed | 0.01474 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:6:2] | passed | 0.014 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:6:3] | passed | 0.01525 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:7:1:1] | passed | 0.01061 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:7:1:2] | passed | 0.01051 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:7:1:3] | passed | 0.01015 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:7:2:1:1] | passed | 0.01122 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:7:2:1:2] | passed | 0.01159 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:7:2:1:3] | passed | 0.01188 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:7:2:2:1] | passed | 0.01124 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:7:2:2:2] | passed | 0.01345 seconds |
+./spec/controllers/command_tower/user_controller_spec.rb[1:2:7:2:2:3] | passed | 0.02824 seconds |
+./spec/integration_test/admin/change_settings_spec.rb[1:1] | passed | 0.09499 seconds |
+./spec/integration_test/application_controller_actions/header_with_reset_spec.rb[1:1] | passed | 0.01962 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:1:1:1] | passed | 0.01182 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:1:1:2] | passed | 0.01197 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:1:1:3] | passed | 0.01158 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:2:1:1:1] | passed | 0.01463 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:2:1:2:1] | passed | 0.01474 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:2:2:1] | passed | 0.01343 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:2:3:1:1] | passed | 0.00235 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:2:3:1:2] | passed | 0.00217 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:2:3:2:1] | passed | 0.01133 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:2:3:2:2] | passed | 0.01188 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:2:3:3:1] | passed | 0.00192 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:2:3:3:2] | passed | 0.00149 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:2:3:4:1] | passed | 0.01225 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:2:3:4:2] | passed | 0.01361 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:2:3:5:1] | passed | 0.01279 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:2:3:5:2] | passed | 0.01167 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:2:3:5:3] | passed | 0.01219 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:3:1:1:1] | passed | 0.01152 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:3:1:2:1] | passed | 0.01138 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:3:1:3] | passed | 0.00218 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:4:1:1:1:1] | passed | 0.01453 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:4:1:1:1:2] | passed | 0.01144 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:4:1:1:2:1] | passed | 0.01101 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:4:1:1:2:2] | passed | 0.01031 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:4:1:1:3:1] | passed | 0.01113 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:4:1:1:3:2] | passed | 0.01016 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:4:1:1:4:1] | passed | 0.01446 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:4:1:2:1:1] | passed | 0.01785 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:4:1:2:2:1] | passed | 0.01223 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:4:2:1:1:1:1] | passed | 0.01077 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:4:2:1:1:1:2] | passed | 0.01245 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:4:2:1:2:1:1] | passed | 0.01318 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:4:2:1:2:1:2:1] | passed | 0.01278 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:4:2:1:2:1:2:2] | passed | 0.01132 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:4:2:1:2:1:3:1] | passed | 0.01199 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:4:2:2:1:1] | passed | 0.01363 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:4:2:3:1:1] | passed | 0.01167 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:1:4:3:1:1] | passed | 0.0154 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:2:1:1:1] | passed | 0.01016 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:2:1:1:2] | passed | 0.00963 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:2:1:1:3] | passed | 0.01041 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:2:1:2:1] | passed | 0.00782 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:2:1:2:2] | passed | 0.01022 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:2:1:2:3] | passed | 0.00928 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:2:1:3:1] | passed | 0.00935 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:2:1:3:2] | passed | 0.01072 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:2:1:3:3] | passed | 0.01129 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:2:1:4:1] | passed | 0.01216 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:2:1:4:2] | passed | 0.01487 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:2:1:4:3] | passed | 0.01533 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:2:1:4:4] | passed | 0.01357 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:3:1:1:1] | passed | 0.0109 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:3:2:1:1] | passed | 0.00716 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:3:2:2:1] | passed | 0.01217 seconds |
+./spec/integration_test/auth/cookie_auth_spec.rb[1:3:3:1:1] | passed | 0.00252 seconds |
+./spec/integration_test/auth/password_reset/integration_spec.rb[1:1] | passed | 0.09034 seconds |
+./spec/integration_test/auth/password_reset/integration_spec.rb[1:2] | passed | 0.03294 seconds |
+./spec/integration_test/auth/password_reset/integration_spec.rb[1:3] | passed | 0.00776 seconds |
+./spec/integration_test/auth/password_reset/integration_spec.rb[1:4] | passed | 0.02031 seconds |
+./spec/integration_test/auth/plain_text/integration_spec.rb[1:1] | passed | 0.09387 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:1:1] | passed | 0.00112 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:1:2:1] | passed | 0.00165 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:2:1] | passed | 0.00096 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:3:1:1] | passed | 0.00095 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:3:2:1] | passed | 0.00113 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:3:3:1:1] | passed | 0.00096 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:3:3:2:1] | passed | 0.00159 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:3:4:1] | passed | 0.00106 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:3:4:2:1] | passed | 0.0013 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:3:5:1] | passed | 0.00086 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:3:5:2:1] | passed | 0.00092 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:3:6] | passed | 0.00095 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:4:1] | passed | 0.00116 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:5:1] | passed | 0.00652 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:6:1:1:1] | passed | 0.00092 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:6:1:2:1:1] | passed | 0.00241 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:6:1:2:2:1] | passed | 0.00358 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:6:1:3:1:1] | passed | 0.00131 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:6:1:3:2:1] | passed | 0.00134 seconds |
+./spec/lib/command_tower/authorization/entity_spec.rb[1:6:2:1] | passed | 0.00095 seconds |
+./spec/lib/command_tower/authorization/role_spec.rb[1:1:1:1] | passed | 0.00088 seconds |
+./spec/lib/command_tower/authorization/role_spec.rb[1:1:2:1] | passed | 0.00081 seconds |
+./spec/lib/command_tower/authorization/role_spec.rb[1:1:3:1] | passed | 0.00064 seconds |
+./spec/lib/command_tower/authorization/role_spec.rb[1:1:4:1] | passed | 0.00108 seconds |
+./spec/lib/command_tower/authorization/role_spec.rb[1:1:5:1] | passed | 0.00161 seconds |
+./spec/lib/command_tower/authorization/role_spec.rb[1:1:6] | passed | 0.00102 seconds |
+./spec/lib/command_tower/authorization/role_spec.rb[1:2:1:1] | passed | 0.00488 seconds |
+./spec/lib/command_tower/authorization/role_spec.rb[1:2:2:1] | passed | 0.00419 seconds |
+./spec/lib/command_tower/authorization/role_spec.rb[1:2:3:1] | passed | 0.0049 seconds |
+./spec/lib/command_tower/authorization/role_spec.rb[1:2:4:1] | passed | 0.00486 seconds |
+./spec/lib/command_tower/authorization/role_spec.rb[1:2:4:2:1] | passed | 0.00437 seconds |
+./spec/lib/command_tower/authorization/role_spec.rb[1:2:4:3:1] | passed | 0.0051 seconds |
+./spec/lib/command_tower/authorization/role_spec.rb[1:2:4:3:2:1] | passed | 0.00442 seconds |
+./spec/lib/command_tower/authorization/role_spec.rb[1:3:1:1] | passed | 0.00157 seconds |
+./spec/lib/command_tower/authorization/role_spec.rb[1:3:2:1] | pending | 0.00002 seconds |
+./spec/lib/command_tower/authorization/role_spec.rb[1:3:2:2] | passed | 0.00137 seconds |
+./spec/lib/command_tower/authorization/role_spec.rb[1:3:3:1] | passed | 0.00104 seconds |
+./spec/lib/command_tower/authorization_spec.rb[1:1:1] | passed | 0.00148 seconds |
+./spec/lib/command_tower/authorization_spec.rb[1:1:2] | passed | 0.00274 seconds |
+./spec/lib/command_tower/authorization_spec.rb[1:1:3] | passed | 0.00154 seconds |
+./spec/lib/command_tower/authorization_spec.rb[1:1:4] | passed | 0.00175 seconds |
+./spec/lib/command_tower/authorization_spec.rb[1:2:1:1] | passed | 0.00114 seconds |
+./spec/lib/command_tower/authorization_spec.rb[1:2:2:1] | passed | 0.0013 seconds |
+./spec/lib/command_tower/authorization_spec.rb[1:2:3:1] | passed | 0.00226 seconds |
+./spec/models/user_secret_spec.rb[1:1:1] | passed | 0.00659 seconds |
+./spec/models/user_secret_spec.rb[1:1:2] | passed | 0.0067 seconds |
+./spec/models/user_secret_spec.rb[1:1:3:1] | passed | 0.00661 seconds |
+./spec/models/user_secret_spec.rb[1:1:3:2] | passed | 0.00775 seconds |
+./spec/models/user_secret_spec.rb[1:1:4:1] | passed | 0.00663 seconds |
+./spec/models/user_secret_spec.rb[1:1:4:2] | passed | 0.00749 seconds |
+./spec/models/user_secret_spec.rb[1:1:5:1] | passed | 0.00665 seconds |
+./spec/models/user_secret_spec.rb[1:1:6:1] | passed | 0.00481 seconds |
+./spec/models/user_secret_spec.rb[1:2:1:1] | passed | 0.00483 seconds |
+./spec/models/user_secret_spec.rb[1:2:2:1] | passed | 0.00523 seconds |
+./spec/models/user_secret_spec.rb[1:2:3:1] | passed | 0.00533 seconds |
+./spec/models/user_secret_spec.rb[1:2:4:1] | passed | 0.00507 seconds |
+./spec/models/user_secret_spec.rb[1:3:1] | passed | 0.02258 seconds |
+./spec/models/user_secret_spec.rb[1:4:1] | passed | 0.00544 seconds |
+./spec/models/user_secret_spec.rb[1:4:2:1] | passed | 0.00438 seconds |
+./spec/models/user_secret_spec.rb[1:4:3:1] | passed | 0.00632 seconds |
+./spec/models/user_secret_spec.rb[1:5:1] | passed | 0.00545 seconds |
+./spec/models/user_secret_spec.rb[1:5:2:1] | passed | 0.00452 seconds |
+./spec/models/user_secret_spec.rb[1:5:3:1] | passed | 0.00582 seconds |
+./spec/models/user_secret_spec.rb[1:6:1] | passed | 0.00517 seconds |
+./spec/models/user_secret_spec.rb[1:6:2:1] | passed | 0.00672 seconds |
+./spec/models/user_secret_spec.rb[1:6:3:1] | passed | 0.00504 seconds |
./spec/models/user_spec.rb[1:1] | pending | 0.0008 seconds |
-./spec/services/command_tower/authorize/validate_spec.rb[1:1:1] | passed | 0.00427 seconds |
-./spec/services/command_tower/authorize/validate_spec.rb[1:1:2] | passed | 0.00565 seconds |
-./spec/services/command_tower/authorize/validate_spec.rb[1:1:3:1] | passed | 0.00472 seconds |
-./spec/services/command_tower/authorize/validate_spec.rb[1:1:3:2] | passed | 0.00405 seconds |
-./spec/services/command_tower/authorize/validate_spec.rb[1:1:4:1] | passed | 0.00402 seconds |
-./spec/services/command_tower/authorize/validate_spec.rb[1:1:4:2] | passed | 0.00408 seconds |
-./spec/services/command_tower/authorize/validate_spec.rb[1:1:5:1] | passed | 0.00389 seconds |
-./spec/services/command_tower/authorize/validate_spec.rb[1:1:5:2] | passed | 0.00394 seconds |
-./spec/services/command_tower/inbox_service/blast/metadata_spec.rb[1:1:1] | passed | 0.00252 seconds |
-./spec/services/command_tower/inbox_service/blast/metadata_spec.rb[1:1:2] | passed | 0.00148 seconds |
-./spec/services/command_tower/inbox_service/blast/metadata_spec.rb[1:1:3:1] | passed | 0.01752 seconds |
-./spec/services/command_tower/inbox_service/blast/metadata_spec.rb[1:1:3:2] | passed | 0.01772 seconds |
-./spec/services/command_tower/inbox_service/blast/new_user_blaster_spec.rb[1:1:1] | passed | 0.03664 seconds |
-./spec/services/command_tower/inbox_service/blast/new_user_blaster_spec.rb[1:1:2] | passed | 0.04588 seconds |
-./spec/services/command_tower/inbox_service/blast/new_user_blaster_spec.rb[1:1:3] | passed | 0.0365 seconds |
-./spec/services/command_tower/inbox_service/blast/retrieve_spec.rb[1:1:1] | passed | 0.00625 seconds |
-./spec/services/command_tower/inbox_service/blast/retrieve_spec.rb[1:1:2] | passed | 0.00644 seconds |
-./spec/services/command_tower/inbox_service/blast/retrieve_spec.rb[1:1:3] | passed | 0.01843 seconds |
-./spec/services/command_tower/inbox_service/blast/retrieve_spec.rb[1:1:4:1] | passed | 0.00164 seconds |
-./spec/services/command_tower/inbox_service/blast/retrieve_spec.rb[1:1:4:2] | passed | 0.0029 seconds |
-./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:1:1] | passed | 0.03054 seconds |
-./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:1:2] | passed | 0.03767 seconds |
-./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:1:3] | passed | 0.02954 seconds |
-./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:1:4] | passed | 0.02828 seconds |
-./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:1:5:1] | passed | 0.03015 seconds |
-./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:1:5:2] | passed | 0.02993 seconds |
-./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:1:5:3] | passed | 0.02831 seconds |
-./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:1:5:4] | passed | 0.02819 seconds |
-./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:2:1] | passed | 0.03617 seconds |
-./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:2:2] | passed | 0.03484 seconds |
-./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:2:3] | passed | 0.03698 seconds |
-./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:2:4] | passed | 0.03502 seconds |
-./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:2:5:1] | passed | 0.02426 seconds |
-./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:2:5:2] | passed | 0.02811 seconds |
-./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:2:5:3] | passed | 0.02374 seconds |
-./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:2:5:4] | passed | 0.0239 seconds |
-./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:1] | passed | 0.00374 seconds |
-./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:2] | passed | 0.00437 seconds |
-./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:3:1] | passed | 0.01355 seconds |
-./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:3:2] | passed | 0.00437 seconds |
-./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:3:3:1] | passed | 0.0141 seconds |
-./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:3:3:2] | passed | 0.01481 seconds |
-./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:1] | passed | 0.01145 seconds |
-./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:2] | passed | 0.01083 seconds |
-./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:3:1] | passed | 0.00832 seconds |
-./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:4:1] | passed | 0.0159 seconds |
-./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:4:2] | passed | 0.01277 seconds |
-./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:5] | passed | 0.01654 seconds |
-./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:6:1] | passed | 0.01045 seconds |
-./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:6:2] | passed | 0.01033 seconds |
-./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:6:3:1] | passed | 0.0073 seconds |
-./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:6:4:1] | passed | 0.00967 seconds |
-./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:6:4:2] | passed | 0.00974 seconds |
-./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:2:1] | passed | 0.01035 seconds |
-./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:2:2] | passed | 0.00962 seconds |
-./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:2:3:1] | passed | 0.00891 seconds |
-./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:2:4:1] | passed | 0.01132 seconds |
-./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:2:4:2] | passed | 0.0106 seconds |
-./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:2:5] | passed | 0.01089 seconds |
-./spec/services/command_tower/inbox_service/message/retrieve_spec.rb[1:1:1] | passed | 0.00538 seconds |
-./spec/services/command_tower/inbox_service/message/retrieve_spec.rb[1:1:2] | passed | 0.00516 seconds |
-./spec/services/command_tower/inbox_service/message/retrieve_spec.rb[1:1:3] | passed | 0.00699 seconds |
-./spec/services/command_tower/inbox_service/message/retrieve_spec.rb[1:1:4:1] | passed | 0.00866 seconds |
-./spec/services/command_tower/inbox_service/message/retrieve_spec.rb[1:1:4:2] | passed | 0.00531 seconds |
-./spec/services/command_tower/inbox_service/message/send_spec.rb[1:1:1] | passed | 0.00418 seconds |
-./spec/services/command_tower/inbox_service/message/send_spec.rb[1:1:2] | passed | 0.00505 seconds |
-./spec/services/command_tower/inbox_service/message/send_spec.rb[1:1:3] | passed | 0.00756 seconds |
-./spec/services/command_tower/inbox_service/message/send_spec.rb[1:1:4:1] | passed | 0.0065 seconds |
-./spec/services/command_tower/inbox_service/message/send_spec.rb[1:1:4:2] | passed | 0.00762 seconds |
-./spec/services/command_tower/inbox_service/message/send_spec.rb[1:1:4:3] | passed | 0.00928 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:1] | passed | 0.00552 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:2] | passed | 0.0063 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:3] | passed | 0.00477 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:4] | passed | 0.00528 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:5:1] | passed | 0.0058 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:5:2] | passed | 0.00579 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:5:3] | passed | 0.00587 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:5:4] | passed | 0.00547 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:6:1] | passed | 0.00525 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:6:2] | passed | 0.00599 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:7:1] | passed | 0.0015 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:7:2] | passed | 0.00122 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:8:1:1] | passed | 0.00481 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:8:1:2] | passed | 0.00545 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:8:2:1] | passed | 0.00461 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:8:2:2] | passed | 0.00468 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:8:3:1] | passed | 0.00489 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:8:3:2] | passed | 0.00458 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:9:1] | passed | 0.00459 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:9:2] | passed | 0.00407 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:10:1:2:1] | passed | 0.00588 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:10:1:2:2] | passed | 0.00778 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:10:1:2:3:1] | passed | 0.0062 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:10:1:2:3:2] | passed | 0.01335 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:10:2:1] | passed | 0.00596 seconds |
-./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:10:2:2] | passed | 0.00486 seconds |
-./spec/services/command_tower/jwt/decode_spec.rb[1:1:1] | passed | 0.006 seconds |
-./spec/services/command_tower/jwt/decode_spec.rb[1:1:2] | passed | 0.01224 seconds |
-./spec/services/command_tower/jwt/decode_spec.rb[1:1:3] | passed | 0.00307 seconds |
-./spec/services/command_tower/jwt/decode_spec.rb[1:1:4:1] | passed | 0.00178 seconds |
-./spec/services/command_tower/jwt/encode_spec.rb[1:1:1] | passed | 0.00154 seconds |
-./spec/services/command_tower/jwt/encode_spec.rb[1:1:2] | passed | 0.00279 seconds |
-./spec/services/command_tower/jwt/encode_spec.rb[1:1:3:1] | passed | 0.00084 seconds |
-./spec/services/command_tower/jwt/encode_spec.rb[1:1:3:2] | passed | 0.00116 seconds |
-./spec/services/command_tower/jwt/login_create_spec.rb[1:1:1] | passed | 0.00417 seconds |
-./spec/services/command_tower/jwt/login_create_spec.rb[1:1:2] | passed | 0.00445 seconds |
-./spec/services/command_tower/jwt/login_create_spec.rb[1:1:3] | passed | 0.00435 seconds |
-./spec/services/command_tower/jwt/login_create_spec.rb[1:1:4:1] | passed | 0.00352 seconds |
-./spec/services/command_tower/jwt/login_create_spec.rb[1:1:4:2] | passed | 0.00338 seconds |
-./spec/services/command_tower/jwt/time_delay_token_spec.rb[1:1:1] | passed | 0.00132 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:1] | passed | 0.00515 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:2] | passed | 0.01178 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:3] | passed | 0.00519 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:4] | passed | 0.00608 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:5:1] | passed | 0.00969 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:6:1] | passed | 0.00631 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:6:2] | passed | 0.005 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:6:3] | passed | 0.00413 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:6:4] | passed | 0.00455 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:6:5] | passed | 0.0048 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:7:1] | passed | 0.0068 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:7:2] | passed | 0.01027 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:7:3] | passed | 0.00666 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:7:4] | passed | 0.00682 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:7:5] | passed | 0.00654 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:8:1] | passed | 0.00197 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:8:2] | passed | 0.00285 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:8:3] | passed | 0.00196 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:8:4] | passed | 0.00198 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:8:5] | passed | 0.00185 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:9:1] | passed | 0.00742 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:9:2] | passed | 0.006 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:9:3] | passed | 0.00565 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:9:4] | passed | 0.00524 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:9:5] | passed | 0.00537 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:10:1] | passed | 0.00135 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:10:2] | passed | 0.00226 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:10:3] | passed | 0.00144 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:10:4] | passed | 0.00132 seconds |
-./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:10:5] | passed | 0.00147 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/required_spec.rb[1:1:1] | passed | 0.00419 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/required_spec.rb[1:1:2] | passed | 0.00496 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/required_spec.rb[1:1:3] | passed | 0.00468 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/required_spec.rb[1:1:4:1] | passed | 0.00445 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/required_spec.rb[1:1:4:2] | passed | 0.00457 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/required_spec.rb[1:1:4:3] | passed | 0.00426 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/send_spec.rb[1:1:1] | passed | 0.0108 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/send_spec.rb[1:1:2] | passed | 0.00813 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/send_spec.rb[1:1:3:1] | passed | 0.00696 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/send_spec.rb[1:1:3:2] | passed | 0.01055 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/send_spec.rb[1:1:3:3] | passed | 0.00802 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:1] | passed | 0.00743 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:2] | passed | 0.03441 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:3:1] | passed | 0.00536 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:3:2] | passed | 0.00639 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:3:3] | passed | 0.00528 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:3:4] | passed | 0.00579 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:3:5] | passed | 0.00549 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:4:1] | passed | 0.00885 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:4:2] | passed | 0.0088 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:4:3] | passed | 0.00794 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:4:4] | passed | 0.00848 seconds |
-./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:4:5] | passed | 0.00743 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:1] | passed | 0.00717 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:2] | passed | 0.00627 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:3] | passed | 0.00652 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:4] | passed | 0.00631 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:5:1] | passed | 0.00347 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:5:2] | passed | 0.00583 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:5:3] | passed | 0.01118 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:5:4] | passed | 0.00332 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:5:5] | passed | 0.00354 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:6:1] | passed | 0.00802 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:6:2] | passed | 0.00537 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:6:3] | passed | 0.00549 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:6:4] | passed | 0.00569 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:6:5] | passed | 0.00633 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:2:1] | passed | 0.00622 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:2:2] | passed | 0.00669 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:2:3] | passed | 0.00728 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:2:4] | passed | 0.00733 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:2:5:1] | passed | 0.00353 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:2:5:2] | passed | 0.00448 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:2:5:3] | passed | 0.00345 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:2:5:4] | passed | 0.00339 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:2:5:5] | passed | 0.00369 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:2:6:1] | passed | 0.00527 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:2:6:2] | passed | 0.00548 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:2:6:3] | passed | 0.00584 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:2:6:4] | passed | 0.00528 seconds |
-./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:2:6:5] | passed | 0.00611 seconds |
-./spec/services/command_tower/secrets/cleanse_spec.rb[1:1:1] | passed | 0.00522 seconds |
-./spec/services/command_tower/secrets/cleanse_spec.rb[1:1:2:1] | passed | 0.00468 seconds |
-./spec/services/command_tower/secrets/cleanse_spec.rb[1:1:2:2:1] | passed | 0.00664 seconds |
-./spec/services/command_tower/secrets/cleanse_spec.rb[1:1:3:1] | passed | 0.00563 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:1:1:1] | passed | 0.00721 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:1:2:1] | passed | 0.01427 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:1:2:2] | passed | 0.01651 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:1:2:3:1] | passed | 0.01487 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:1:2:3:2] | passed | 0.02179 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:1:3:1] | passed | 0.00819 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:2:1:1] | passed | 0.00684 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:2:2:1] | passed | 0.01584 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:2:2:2] | passed | 0.01644 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:2:2:3:1] | passed | 0.01635 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:2:2:3:2] | passed | 0.01659 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:2:3:1] | passed | 0.00793 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:3:1] | passed | 0.00699 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:3:2:1] | passed | 0.00703 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:3:3:1] | passed | 0.01791 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:3:3:2] | passed | 0.01652 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:3:3:3:1] | passed | 0.01519 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:3:3:3:2] | passed | 0.01689 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:3:4:1] | passed | 0.00851 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:1:1:1] | passed | 0.00707 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:1:2:1] | passed | 0.01849 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:1:2:2] | passed | 0.02363 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:1:2:3:1] | passed | 0.01614 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:1:2:3:2] | passed | 0.01806 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:1:3:1] | passed | 0.00759 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:2:1:1] | passed | 0.00649 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:2:2:1] | passed | 0.01481 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:2:2:2] | passed | 0.01611 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:2:2:3:1] | passed | 0.01733 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:2:2:3:2] | passed | 0.01799 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:2:3:1] | passed | 0.00789 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:3:1] | passed | 0.0078 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:3:2:1] | passed | 0.00701 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:3:3:1] | passed | 0.01551 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:3:3:2] | passed | 0.01612 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:3:3:3:1] | passed | 0.01493 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:3:3:3:2] | passed | 0.01793 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:3:4:1] | passed | 0.00784 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:1:1:1] | passed | 0.00693 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:1:2:1] | passed | 0.01567 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:1:2:2] | passed | 0.0156 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:1:2:3:1] | passed | 0.01625 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:1:2:3:2] | passed | 0.01857 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:1:3:1] | passed | 0.00819 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:2:1:1] | passed | 0.00643 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:2:2:1] | passed | 0.01424 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:2:2:2] | passed | 0.0151 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:2:2:3:1] | passed | 0.01469 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:2:2:3:2] | passed | 0.01627 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:2:3:1] | passed | 0.00806 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:3:1] | passed | 0.00748 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:3:2:1] | passed | 0.00702 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:3:3:1] | passed | 0.0157 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:3:3:2] | passed | 0.01835 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:3:3:3:1] | passed | 0.01603 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:3:3:3:2] | passed | 0.01846 seconds |
-./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:3:4:1] | passed | 0.00821 seconds |
-./spec/services/command_tower/secrets/verify_spec.rb[1:1:1] | passed | 0.00548 seconds |
-./spec/services/command_tower/secrets/verify_spec.rb[1:1:2] | passed | 0.00463 seconds |
-./spec/services/command_tower/secrets/verify_spec.rb[1:1:3:1] | passed | 0.00141 seconds |
-./spec/services/command_tower/secrets/verify_spec.rb[1:1:3:2] | passed | 0.00168 seconds |
-./spec/services/command_tower/secrets/verify_spec.rb[1:1:3:3] | passed | 0.00184 seconds |
-./spec/services/command_tower/secrets/verify_spec.rb[1:1:4:1] | passed | 0.00611 seconds |
-./spec/services/command_tower/secrets/verify_spec.rb[1:1:4:2] | passed | 0.00851 seconds |
-./spec/services/command_tower/secrets/verify_spec.rb[1:1:4:3] | passed | 0.0068 seconds |
-./spec/services/command_tower/secrets/verify_spec.rb[1:1:4:4] | passed | 0.00672 seconds |
-./spec/services/command_tower/secrets/verify_spec.rb[1:1:4:5:1] | passed | 0.00647 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:1:1:1:1] | passed | 0.00168 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:1:1:2:1] | passed | 0.00247 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:1:1:2:2:1] | passed | 0.00181 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:1:2] | passed | 0.00199 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:2:1:1:1] | passed | 0.00218 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:2:1:2:1] | passed | 0.00239 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:2:1:2:2:1] | passed | 0.00253 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:2:2] | passed | 0.00265 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:3:1:1:1:1] | passed | 0.00274 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:3:1:1:2:1] | passed | 0.00246 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:3:1:2:1] | passed | 0.00263 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:3:1:3:1] | passed | 0.00309 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:3:1:3:2:1] | passed | 0.00253 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:3:2] | passed | 0.00412 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:4:1:1:1] | passed | 0.00169 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:4:1:2:1] | passed | 0.00206 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:4:1:3:1] | passed | 0.00322 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:4:1:3:2:1] | passed | 0.00222 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:4:2] | passed | 0.00614 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:5:1:1:1:1] | passed | 0.00211 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:5:1:1:2:1] | passed | 0.00238 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:5:1:2:1] | passed | 0.00231 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:5:1:3:1] | passed | 0.00257 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:5:1:3:2:1] | passed | 0.00226 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:5:2] | passed | 0.00222 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:1:1] | passed | 0.00411 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:1:2] | passed | 0.00235 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:1:3:1] | passed | 0.00245 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:1:3:2] | passed | 0.00277 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:1:4:1:1] | passed | 0.0029 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:1:4:2:1] | passed | 0.00273 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:1:4:2:2] | passed | 0.0026 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:1:4:3:1] | passed | 0.00249 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:1:4:3:2] | passed | 0.00284 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:1:4:4:1] | passed | 0.00287 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:1:4:4:2] | passed | 0.00268 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:2:1] | passed | 0.00247 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:2:2] | passed | 0.00244 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:2:3:1] | passed | 0.00265 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:2:4:1:1] | passed | 0.00298 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:2:4:2:1] | passed | 0.00224 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:2:4:2:2] | passed | 0.00239 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:2:4:3:1] | passed | 0.00292 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:2:4:3:2] | passed | 0.00269 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:2:4:4:1] | passed | 0.00229 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:2:4:4:2] | passed | 0.00345 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:3:1] | passed | 0.00215 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:3:2] | passed | 0.00237 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:3:3:1] | passed | 0.00345 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:3:4:1:1] | passed | 0.00328 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:3:4:2:1] | passed | 0.00209 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:3:4:2:2] | passed | 0.0035 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:3:4:3:1] | passed | 0.0027 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:3:4:3:2] | passed | 0.00245 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:3:4:4:1] | passed | 0.00263 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:3:4:4:2] | passed | 0.00243 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:4:1] | passed | 0.00281 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:4:2] | passed | 0.00265 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:4:3] | passed | 0.00225 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:4:4:1] | passed | 0.00242 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:4:5:1:1] | passed | 0.00293 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:4:5:2:1] | passed | 0.00325 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:4:5:2:2] | passed | 0.0023 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:4:5:3:1] | passed | 0.00337 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:4:5:3:2] | passed | 0.00312 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:4:5:4:1] | passed | 0.00232 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:4:5:4:2] | passed | 0.00253 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:5:1] | passed | 0.00233 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:5:2] | passed | 0.00258 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:5:3:1] | passed | 0.00303 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:5:4:1:1] | passed | 0.00251 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:5:4:2:1] | passed | 0.00236 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:5:4:2:2] | passed | 0.00273 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:5:4:3:1] | passed | 0.00268 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:5:4:3:2] | passed | 0.00249 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:5:4:4:1] | passed | 0.00393 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:6:5:4:4:2] | passed | 0.00716 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:7:1:1] | passed | 0.00243 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:7:1:2:1] | passed | 0.00268 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:7:1:2:2:1] | passed | 0.00269 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:7:1:3:1] | passed | 0.0077 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:7:1:3:2:1] | passed | 0.00286 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:1:1] | passed | 0.00246 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:2:1] | passed | 0.00325 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:2:2] | passed | 0.00309 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:2:3] | passed | 0.00299 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:2:4] | passed | 0.00328 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:3:1] | passed | 0.00276 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:3:2] | passed | 0.00284 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:3:3:1] | passed | 0.00295 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:3:4:1:1] | passed | 0.00324 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:3:4:1:2:1] | passed | 0.00286 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:3:4:2:1] | passed | 0.00295 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:3:4:2:2] | passed | 0.0032 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:3:4:2:3:1] | passed | 0.00317 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:3:4:2:3:2] | passed | 0.00314 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:3:4:3:1] | passed | 0.00295 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:3:4:3:2] | passed | 0.00271 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:3:4:3:3:1] | passed | 0.00315 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:3:4:3:3:2] | passed | 0.00273 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:4:1] | passed | 0.00257 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:4:2] | passed | 0.00273 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:4:3:1] | passed | 0.00264 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:4:4:1:1] | passed | 0.00276 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:4:4:1:2:1] | passed | 0.00262 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:4:4:2:1] | passed | 0.00294 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:4:4:2:2] | passed | 0.0028 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:4:4:2:3:1] | passed | 0.00278 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:4:4:2:3:2] | passed | 0.0027 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:4:4:3:1] | passed | 0.00269 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:4:4:3:2] | passed | 0.00251 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:4:4:3:3:1] | passed | 0.00258 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:4:4:3:3:2] | passed | 0.00251 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:5:1] | passed | 0.00294 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:5:2] | passed | 0.00297 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:5:3:1] | passed | 0.00304 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:5:4:1:1] | passed | 0.00298 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:5:4:1:2:1] | passed | 0.00303 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:5:4:2:1] | passed | 0.00334 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:5:4:2:2] | passed | 0.0031 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:5:4:2:3:1] | passed | 0.00334 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:5:4:2:3:2] | passed | 0.00326 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:5:4:3:1] | passed | 0.00323 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:5:4:3:2] | passed | 0.00301 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:5:4:3:3:1] | passed | 0.00304 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:5:4:3:3:2] | passed | 0.00266 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:6:1] | passed | 0.00285 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:6:2] | passed | 0.00376 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:6:3:1] | passed | 0.00301 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:6:4:1:1] | passed | 0.00293 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:6:4:1:2:1] | passed | 0.00266 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:6:4:2:1] | passed | 0.00274 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:6:4:2:2] | passed | 0.00298 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:6:4:2:3:1] | passed | 0.00262 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:6:4:2:3:2] | passed | 0.00291 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:6:4:3:1] | passed | 0.00419 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:6:4:3:2] | passed | 0.0028 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:6:4:3:3:1] | passed | 0.00273 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:6:4:3:3:2] | passed | 0.00261 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:7:1] | passed | 0.00332 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:7:2] | passed | 0.00297 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:7:3:1] | passed | 0.00305 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:7:4:1:1] | passed | 0.00344 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:7:4:1:2:1] | passed | 0.0032 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:7:4:2:1] | passed | 0.00314 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:7:4:2:2] | passed | 0.00304 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:7:4:2:3:1] | passed | 0.00279 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:7:4:2:3:2] | passed | 0.00298 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:7:4:3:1] | passed | 0.00286 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:7:4:3:2] | passed | 0.00305 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:7:4:3:3:1] | passed | 0.00353 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:7:4:3:3:2] | passed | 0.00273 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:8:1:1] | passed | 0.00246 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:8:2:1] | passed | 0.00265 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:1:1] | passed | 0.00289 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:1:2] | passed | 0.0031 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:1:3:1] | passed | 0.0026 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:1:1] | passed | 0.00302 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:1:2:1] | passed | 0.00315 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:2:1] | passed | 0.00315 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:2:2] | passed | 0.00271 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:2:3:1] | passed | 0.00324 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:2:3:2] | passed | 0.00295 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:3:1] | passed | 0.00275 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:3:2] | passed | 0.00277 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:3:3:1] | passed | 0.00295 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:3:3:2] | passed | 0.00303 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:2] | passed | 0.00246 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:3] | passed | 0.00278 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:4:1] | passed | 0.00241 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:5:1:1] | passed | 0.00262 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:5:1:2:1] | passed | 0.00275 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:5:2:1] | passed | 0.00294 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:5:2:2] | passed | 0.00267 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:5:2:3:1] | passed | 0.00269 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:5:2:3:2] | passed | 0.00304 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:5:3:1] | passed | 0.00397 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:5:3:2] | passed | 0.00344 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:5:3:3:1] | passed | 0.00519 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:5:3:3:2] | passed | 0.00267 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:1] | passed | 0.00316 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:2] | passed | 0.00308 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:3:1] | passed | 0.00304 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:1:1] | passed | 0.0032 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:1:2:1] | passed | 0.00287 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:2:1] | passed | 0.00291 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:2:2] | passed | 0.00308 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:2:3:1] | passed | 0.00322 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:2:3:2] | passed | 0.00295 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:3:1] | passed | 0.00297 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:3:2] | passed | 0.00266 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:3:3:1] | passed | 0.00301 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:3:3:2] | passed | 0.00327 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:1] | passed | 0.0031 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:2] | passed | 0.00284 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:3:1] | passed | 0.00276 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:1:1] | passed | 0.00308 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:1:2:1] | passed | 0.00281 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:2:1] | passed | 0.00284 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:2:2] | passed | 0.0032 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:2:3:1] | passed | 0.00308 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:2:3:2] | passed | 0.00277 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:3:1] | passed | 0.00294 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:3:2] | passed | 0.00278 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:3:3:1] | passed | 0.00271 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:3:3:2] | passed | 0.00305 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:1] | passed | 0.00286 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:2] | passed | 0.00265 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:3:1] | passed | 0.00269 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:1:1] | passed | 0.00322 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:1:2:1] | passed | 0.00283 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:2:1] | passed | 0.0034 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:2:2] | passed | 0.00301 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:2:3:1] | passed | 0.00277 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:2:3:2] | passed | 0.003 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:3:1] | passed | 0.00341 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:3:2] | passed | 0.01273 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:3:3:1] | passed | 0.00278 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:3:3:2] | passed | 0.00308 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:1] | passed | 0.00301 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:2] | passed | 0.00276 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:3:1] | passed | 0.00323 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:1:1] | passed | 0.00339 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:1:2:1] | passed | 0.00303 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:2:1] | passed | 0.00288 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:2:2] | passed | 0.00312 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:2:3:1] | passed | 0.00277 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:2:3:2] | passed | 0.00281 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:3:1] | passed | 0.00292 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:3:2] | passed | 0.00266 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:3:3:1] | passed | 0.00308 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:3:3:2] | passed | 0.00274 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:1] | passed | 0.00269 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:2] | passed | 0.00282 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:3:1] | passed | 0.00314 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:1:1] | passed | 0.00293 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:1:2:1] | passed | 0.00331 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:2:1] | passed | 0.00289 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:2:2] | passed | 0.00294 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:2:3:1] | passed | 0.00326 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:2:3:2] | passed | 0.00278 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:3:1] | passed | 0.00332 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:3:2] | passed | 0.0028 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:3:3:1] | passed | 0.00325 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:3:3:2] | passed | 0.00277 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:10:1] | passed | 0.00312 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:10:2] | passed | 0.00272 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:10:3:1] | passed | 0.00273 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:10:4:1:1] | passed | 0.00295 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:10:4:1:2:1] | passed | 0.00327 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:10:4:2:1] | passed | 0.00334 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:10:4:2:2] | passed | 0.00285 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:10:4:2:3:1] | passed | 0.00314 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:10:4:2:3:2] | passed | 0.00292 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:10:4:3:1] | passed | 0.00301 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:10:4:3:2] | passed | 0.00271 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:10:4:3:3:1] | passed | 0.00301 seconds |
-./spec/services/command_tower/service_base_spec.rb[1:8:10:4:3:3:2] | passed | 0.00262 seconds |
-./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:1:1] | passed | 0.00405 seconds |
-./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:1:2] | passed | 0.00434 seconds |
-./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:1:3:1] | passed | 0.00321 seconds |
-./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:1:3:2] | passed | 0.00364 seconds |
-./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:1:3:3] | passed | 0.004 seconds |
-./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:2:1] | passed | 0.00438 seconds |
-./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:2:2] | passed | 0.00491 seconds |
-./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:2:3:1] | passed | 0.0061 seconds |
-./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:2:3:2] | passed | 0.00348 seconds |
-./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:2:3:3] | passed | 0.00463 seconds |
-./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:3:1] | passed | 0.00417 seconds |
-./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:3:2] | passed | 0.00472 seconds |
-./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:3:3] | passed | 0.00439 seconds |
-./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:3:4:1] | passed | 0.0033 seconds |
-./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:3:4:2] | passed | 0.00335 seconds |
-./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:3:4:3] | passed | 0.00385 seconds |
-./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:2:1] | passed | 0.00429 seconds |
-./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:2:2] | passed | 0.00456 seconds |
-./spec/services/command_tower/user_attributes/roles_spec.rb[1:1:1] | passed | 0.00801 seconds |
-./spec/services/command_tower/user_attributes/roles_spec.rb[1:1:2] | passed | 0.01665 seconds |
-./spec/services/command_tower/user_attributes/roles_spec.rb[1:1:3:1] | passed | 0.00599 seconds |
-./spec/services/command_tower/user_attributes/roles_spec.rb[1:1:3:2] | passed | 0.00642 seconds |
-./spec/services/command_tower/user_attributes/roles_spec.rb[1:1:3:3] | passed | 0.00656 seconds |
-./spec/services/command_tower/user_attributes/roles_spec.rb[1:1:4:1] | passed | 0.00747 seconds |
-./spec/services/command_tower/user_attributes/roles_spec.rb[1:1:4:2] | passed | 0.00804 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:1] | passed | 0.00654 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:2] | passed | 0.01467 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:1] | passed | 0.08888 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:2] | passed | 0.11485 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:3:1] | passed | 0.08691 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:3:2] | passed | 0.13117 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:3:3:1] | passed | 0.09961 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:3:3:2] | passed | 0.10241 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:3:4:1] | passed | 0.09281 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:3:4:2] | passed | 0.10179 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:4:1] | passed | 0.09173 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:4:2] | passed | 0.09049 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:4:3:1] | passed | 0.08695 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:4:3:2] | passed | 0.09775 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:4:4:1] | passed | 0.08954 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:4:4:2] | passed | 0.09003 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:5:1] | passed | 0.08396 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:5:2] | passed | 0.08102 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:5:3:1] | passed | 0.07756 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:6:1:1] | passed | 0.09774 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:6:1:2] | passed | 0.08646 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:6:2:1] | passed | 0.09158 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:6:2:2] | passed | 0.09815 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:6:3:1] | passed | 0.09662 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:1:6:3:2] | passed | 0.09132 seconds |
+./spec/services/command_tower/admin_service/users_spec.rb[1:1:3:1:2:1] | passed | 0.00705 seconds |
+./spec/services/command_tower/authorize/validate_spec.rb[1:1:1] | passed | 0.00577 seconds |
+./spec/services/command_tower/authorize/validate_spec.rb[1:1:2] | passed | 0.00483 seconds |
+./spec/services/command_tower/authorize/validate_spec.rb[1:1:3:1] | passed | 0.00618 seconds |
+./spec/services/command_tower/authorize/validate_spec.rb[1:1:3:2] | passed | 0.00531 seconds |
+./spec/services/command_tower/authorize/validate_spec.rb[1:1:4:1] | passed | 0.00528 seconds |
+./spec/services/command_tower/authorize/validate_spec.rb[1:1:4:2] | passed | 0.00536 seconds |
+./spec/services/command_tower/authorize/validate_spec.rb[1:1:5:1] | passed | 0.00525 seconds |
+./spec/services/command_tower/authorize/validate_spec.rb[1:1:5:2] | passed | 0.00618 seconds |
+./spec/services/command_tower/inbox_service/blast/metadata_spec.rb[1:1:1] | passed | 0.00331 seconds |
+./spec/services/command_tower/inbox_service/blast/metadata_spec.rb[1:1:2] | passed | 0.01922 seconds |
+./spec/services/command_tower/inbox_service/blast/metadata_spec.rb[1:1:3:1] | passed | 0.03238 seconds |
+./spec/services/command_tower/inbox_service/blast/metadata_spec.rb[1:1:3:2] | passed | 0.04255 seconds |
+./spec/services/command_tower/inbox_service/blast/new_user_blaster_spec.rb[1:1:1] | passed | 0.05949 seconds |
+./spec/services/command_tower/inbox_service/blast/new_user_blaster_spec.rb[1:1:2] | passed | 0.08439 seconds |
+./spec/services/command_tower/inbox_service/blast/new_user_blaster_spec.rb[1:1:3] | passed | 0.19818 seconds |
+./spec/services/command_tower/inbox_service/blast/retrieve_spec.rb[1:1:1] | passed | 0.00844 seconds |
+./spec/services/command_tower/inbox_service/blast/retrieve_spec.rb[1:1:2] | passed | 0.00792 seconds |
+./spec/services/command_tower/inbox_service/blast/retrieve_spec.rb[1:1:3] | passed | 0.00783 seconds |
+./spec/services/command_tower/inbox_service/blast/retrieve_spec.rb[1:1:4:1] | passed | 0.00166 seconds |
+./spec/services/command_tower/inbox_service/blast/retrieve_spec.rb[1:1:4:2] | passed | 0.00197 seconds |
+./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:1:1] | passed | 0.04285 seconds |
+./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:1:2] | passed | 0.04384 seconds |
+./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:1:3] | passed | 0.03963 seconds |
+./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:1:4] | passed | 0.04214 seconds |
+./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:1:5:1] | passed | 0.03889 seconds |
+./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:1:5:2] | passed | 0.04295 seconds |
+./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:1:5:3] | passed | 0.03874 seconds |
+./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:1:5:4] | passed | 0.04264 seconds |
+./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:2:1] | passed | 0.04601 seconds |
+./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:2:2] | passed | 0.04567 seconds |
+./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:2:3] | passed | 0.04856 seconds |
+./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:2:4] | passed | 0.04382 seconds |
+./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:2:5:1] | passed | 0.15588 seconds |
+./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:2:5:2] | passed | 0.03469 seconds |
+./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:2:5:3] | passed | 0.07686 seconds |
+./spec/services/command_tower/inbox_service/blast/upsert_spec.rb[1:1:2:5:4] | passed | 0.04725 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:1] | passed | 0.00568 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:2] | passed | 0.0049 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:3:1] | passed | 0.00678 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:3:2] | passed | 0.00843 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:3:3] | passed | 0.00649 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:3:4:1] | passed | 0.01949 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:3:4:2] | passed | 0.01943 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:1] | passed | 0.04311 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:2] | passed | 0.04832 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:3:1] | passed | 0.05277 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:3:2] | passed | 0.043 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:3:3:1] | passed | 0.05291 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:3:3:2] | passed | 0.04438 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:3:4:1] | passed | 0.04546 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:3:4:2] | passed | 0.0458 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:4:1] | passed | 0.04464 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:4:2] | passed | 0.04765 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:4:3:1] | passed | 0.04647 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:4:3:2] | passed | 0.05199 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:4:4:1] | passed | 0.05694 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:4:4:2] | passed | 0.04868 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:5:1] | passed | 0.04986 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:5:2] | passed | 0.04491 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:5:3:1] | passed | 0.04695 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:6:1:1] | passed | 0.04554 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:6:1:2] | passed | 0.04586 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:6:2:1] | passed | 0.27576 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:6:2:2] | passed | 0.08925 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:6:3:1] | passed | 0.08353 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:1:6:3:2] | passed | 0.06097 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:4:1:2:1] | passed | 0.0056 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:5:1:1] | passed | 0.00834 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:5:1:2] | passed | 0.00557 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:5:2:1:1] | passed | 0.04279 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:5:2:1:2] | passed | 0.03729 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:5:2:1:3] | passed | 0.03887 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:5:2:2:1] | passed | 0.06734 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:5:2:2:2] | passed | 0.03836 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:5:2:2:3] | passed | 0.03855 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:5:2:3:1] | passed | 0.05893 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:5:2:3:2] | passed | 0.04045 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:5:2:4:1] | passed | 0.04492 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:5:2:4:2] | passed | 0.04174 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:5:2:4:3] | passed | 0.04506 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:5:2:5:1] | passed | 0.04125 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:5:2:5:2] | passed | 0.04813 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:5:2:5:3] | passed | 0.0411 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:5:2:6:1] | passed | 0.01962 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:5:2:6:2] | passed | 0.0187 seconds |
+./spec/services/command_tower/inbox_service/message/metadata_spec.rb[1:1:5:2:6:3] | passed | 0.0204 seconds |
+./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:1] | passed | 0.01685 seconds |
+./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:2] | passed | 0.01612 seconds |
+./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:3:1] | passed | 0.01257 seconds |
+./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:4:1] | passed | 0.01673 seconds |
+./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:4:2] | passed | 0.01641 seconds |
+./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:5] | passed | 0.01992 seconds |
+./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:6:1] | passed | 0.01806 seconds |
+./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:6:2] | passed | 0.01939 seconds |
+./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:6:3:1] | passed | 0.01399 seconds |
+./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:6:4:1] | passed | 0.01695 seconds |
+./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:1:6:4:2] | passed | 0.01557 seconds |
+./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:2:1] | passed | 0.01807 seconds |
+./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:2:2] | passed | 0.01725 seconds |
+./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:2:3:1] | passed | 0.01313 seconds |
+./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:2:4:1] | passed | 0.01934 seconds |
+./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:2:4:2] | passed | 0.01805 seconds |
+./spec/services/command_tower/inbox_service/message/modify_spec.rb[1:1:2:5] | passed | 0.01824 seconds |
+./spec/services/command_tower/inbox_service/message/retrieve_spec.rb[1:1:1] | passed | 0.00695 seconds |
+./spec/services/command_tower/inbox_service/message/retrieve_spec.rb[1:1:2] | passed | 0.00758 seconds |
+./spec/services/command_tower/inbox_service/message/retrieve_spec.rb[1:1:3] | passed | 0.00844 seconds |
+./spec/services/command_tower/inbox_service/message/retrieve_spec.rb[1:1:4:1] | passed | 0.00593 seconds |
+./spec/services/command_tower/inbox_service/message/retrieve_spec.rb[1:1:4:2] | passed | 0.00561 seconds |
+./spec/services/command_tower/inbox_service/message/send_spec.rb[1:1:1] | passed | 0.00541 seconds |
+./spec/services/command_tower/inbox_service/message/send_spec.rb[1:1:2] | passed | 0.00943 seconds |
+./spec/services/command_tower/inbox_service/message/send_spec.rb[1:1:3] | passed | 0.00634 seconds |
+./spec/services/command_tower/inbox_service/message/send_spec.rb[1:1:4:1] | passed | 0.0088 seconds |
+./spec/services/command_tower/inbox_service/message/send_spec.rb[1:1:4:2] | passed | 0.01013 seconds |
+./spec/services/command_tower/inbox_service/message/send_spec.rb[1:1:4:3] | passed | 0.01181 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:1] | passed | 0.00888 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:2] | passed | 0.0101 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:3] | passed | 0.00912 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:4] | passed | 0.00927 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:5:1] | passed | 0.01395 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:5:2] | passed | 0.00975 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:5:3] | passed | 0.00845 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:5:4] | passed | 0.00866 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:6:1] | passed | 0.00915 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:6:2] | passed | 0.01022 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:7:1] | passed | 0.00228 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:7:2] | passed | 0.00164 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:8:1:1] | passed | 0.00775 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:8:1:2] | passed | 0.00794 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:8:2:1] | passed | 0.00769 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:8:2:2] | passed | 0.00774 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:8:3:1] | passed | 0.00727 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:8:3:2] | passed | 0.00754 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:9:1] | passed | 0.00687 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:9:2] | passed | 0.00663 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:10:1:2:1] | passed | 0.01951 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:10:1:2:2] | passed | 0.01273 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:10:1:2:3:1] | passed | 0.01053 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:10:1:2:3:2] | passed | 0.01122 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:10:1:2:3:3] | passed | 0.01273 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:10:1:2:3:4] | passed | 0.01147 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:10:2:1] | passed | 0.01012 seconds |
+./spec/services/command_tower/jwt/authenticate_user_spec.rb[1:1:10:2:2] | passed | 0.0118 seconds |
+./spec/services/command_tower/jwt/decode_spec.rb[1:1:1] | passed | 0.00183 seconds |
+./spec/services/command_tower/jwt/decode_spec.rb[1:1:2] | passed | 0.0011 seconds |
+./spec/services/command_tower/jwt/decode_spec.rb[1:1:3] | passed | 0.0025 seconds |
+./spec/services/command_tower/jwt/decode_spec.rb[1:1:4:1] | passed | 0.00117 seconds |
+./spec/services/command_tower/jwt/encode_spec.rb[1:1:1] | passed | 0.00126 seconds |
+./spec/services/command_tower/jwt/encode_spec.rb[1:1:2] | passed | 0.00078 seconds |
+./spec/services/command_tower/jwt/encode_spec.rb[1:1:3:1] | passed | 0.0008 seconds |
+./spec/services/command_tower/jwt/encode_spec.rb[1:1:3:2] | passed | 0.00083 seconds |
+./spec/services/command_tower/jwt/login_create_spec.rb[1:1:1] | passed | 0.00576 seconds |
+./spec/services/command_tower/jwt/login_create_spec.rb[1:1:2] | passed | 0.01034 seconds |
+./spec/services/command_tower/jwt/login_create_spec.rb[1:1:3] | passed | 0.00893 seconds |
+./spec/services/command_tower/jwt/login_create_spec.rb[1:1:4:1] | passed | 0.02733 seconds |
+./spec/services/command_tower/jwt/login_create_spec.rb[1:1:4:2] | passed | 0.00982 seconds |
+./spec/services/command_tower/jwt/time_delay_token_spec.rb[1:1:1] | passed | 0.00149 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:1:1] | passed | 0.01198 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:1:2] | passed | 0.01592 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:1:3] | passed | 0.01517 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:1:4] | passed | 0.0141 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:1:5] | passed | 0.01426 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:1:6] | passed | 0.01671 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:1:7] | passed | 0.01304 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:1:8] | passed | 0.01577 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:1:9] | passed | 0.01809 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:2:1] | passed | 0.00798 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:2:2] | passed | 0.00871 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:3:1] | passed | 0.00945 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:3:2] | passed | 0.00707 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:4:1] | passed | 0.0091 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:4:2] | passed | 0.00791 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:5:1] | passed | 0.01141 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:5:2] | passed | 0.01211 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:6:1] | passed | 0.00932 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:6:2] | passed | 0.00951 seconds |
+./spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb[1:1:7:1] | passed | 0.00585 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:1] | passed | 0.00749 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:2] | passed | 0.00883 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:3] | passed | 0.01434 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:4] | passed | 0.00989 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:5:1] | passed | 0.02028 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:6:1] | passed | 0.00719 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:6:2] | passed | 0.00729 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:6:3] | passed | 0.00677 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:6:4] | passed | 0.0079 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:6:5] | passed | 0.00627 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:7:1] | passed | 0.01157 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:7:2] | passed | 0.01039 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:7:3] | passed | 0.01132 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:7:4] | passed | 0.00974 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:7:5] | passed | 0.00932 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:8:1] | passed | 0.00868 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:8:2] | passed | 0.00402 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:8:3] | passed | 0.00263 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:8:4] | passed | 0.00874 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:8:5] | passed | 0.00702 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:9:1] | passed | 0.01139 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:9:2] | passed | 0.0381 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:9:3] | passed | 0.00883 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:9:4] | passed | 0.03014 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:9:5] | passed | 0.04 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:10:1] | passed | 0.00175 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:10:2] | passed | 0.00242 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:10:3] | passed | 0.00149 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:10:4] | passed | 0.0017 seconds |
+./spec/services/command_tower/login_strategy/plain_text/create_spec.rb[1:1:10:5] | passed | 0.00128 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/required_spec.rb[1:1:1] | passed | 0.00739 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/required_spec.rb[1:1:2] | passed | 0.00683 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/required_spec.rb[1:1:3] | passed | 0.00659 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/required_spec.rb[1:1:4:1] | passed | 0.00748 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/required_spec.rb[1:1:4:2] | passed | 0.00777 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/required_spec.rb[1:1:4:3] | passed | 0.00725 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/send_spec.rb[1:1:1] | passed | 0.00998 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/send_spec.rb[1:1:2] | passed | 0.00891 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/send_spec.rb[1:1:3:1] | passed | 0.00793 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/send_spec.rb[1:1:3:2] | passed | 0.00939 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/send_spec.rb[1:1:3:3] | passed | 0.00812 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:1] | passed | 0.01114 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:2] | passed | 0.01886 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:3:1] | passed | 0.00895 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:3:2] | passed | 0.00886 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:3:3] | passed | 0.00979 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:3:4] | passed | 0.01137 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:3:5] | passed | 0.00901 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:4:1] | passed | 0.01155 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:4:2] | passed | 0.01239 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:4:3] | passed | 0.01167 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:4:4] | passed | 0.01094 seconds |
+./spec/services/command_tower/login_strategy/plain_text/email_verification/verify_spec.rb[1:1:4:5] | passed | 0.01084 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:1:1] | passed | 0.01324 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:1:2] | passed | 0.01298 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:1:3] | passed | 0.01243 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:1:4] | passed | 0.01177 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:1:5:1] | passed | 0.0063 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:1:5:2] | passed | 0.00766 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:1:5:3] | passed | 0.00649 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:1:5:4] | passed | 0.00632 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:1:5:5] | passed | 0.00593 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:1:6:1] | passed | 0.00886 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:1:6:2] | passed | 0.00836 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:1:6:3] | passed | 0.00884 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:1:6:4] | passed | 0.01393 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:1:6:5] | passed | 0.00989 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:2:1] | passed | 0.01097 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:2:2] | passed | 0.01296 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:2:3] | passed | 0.22572 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:2:4] | passed | 0.08098 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:2:5:1] | passed | 0.01262 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:2:5:2] | passed | 0.01072 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:2:5:3] | passed | 0.0552 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:2:5:4] | passed | 0.01177 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:2:5:5] | passed | 0.01246 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:2:6:1] | passed | 0.01195 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:2:6:2] | passed | 0.01894 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:2:6:3] | passed | 0.01561 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:2:6:4] | passed | 0.01036 seconds |
+./spec/services/command_tower/login_strategy/plain_text/login_spec.rb[1:1:1:2:6:5] | passed | 0.03372 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:1:1] | passed | 0.01209 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:1:2] | passed | 0.01228 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:1:3] | passed | 0.01172 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:1:4] | passed | 0.01165 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:1:5] | passed | 0.01206 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:2:1] | passed | 0.00888 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:2:2] | passed | 0.00655 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:2:3] | passed | 0.00665 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:3:1] | passed | 0.00808 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:3:2] | passed | 0.00587 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:4:1] | passed | 0.00428 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:4:2] | passed | 0.00272 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:4:3] | passed | 0.00416 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:5:1] | passed | 0.00954 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:5:2] | passed | 0.00972 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:5:3] | passed | 0.011 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:6:1] | passed | 0.01049 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:6:2] | passed | 0.01005 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:6:3] | passed | 0.01025 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:7:1] | passed | 0.00134 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:7:2] | passed | 0.00196 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:8:1] | passed | 0.00623 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:8:2] | passed | 0.00779 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:9:1:1] | passed | 0.01164 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:9:1:2] | passed | 0.0139 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:9:2:1] | passed | 0.0107 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:9:2:2] | passed | 0.01227 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:9:3:1] | passed | 0.01 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:9:3:2] | passed | 0.00815 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:9:3:3] | passed | 0.00813 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:10:1:1] | passed | 0.00609 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:10:1:2] | passed | 0.00506 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:10:1:3] | passed | 0.00797 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:10:2:1] | passed | 0.00977 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:10:2:2] | passed | 0.01182 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:10:3:1] | passed | 0.01066 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:10:3:2] | passed | 0.00816 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:10:3:3] | passed | 0.00882 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:11:1:1] | passed | 0.00982 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb[1:1:11:2:1] | passed | 0.01029 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:1:1] | passed | 0.01028 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:1:2] | passed | 0.00861 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:1:3] | passed | 0.00846 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:1:4] | passed | 0.00998 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:1:5] | passed | 0.01087 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:1:6:1] | passed | 0.0086 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:1:6:2] | passed | 0.00782 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:1:6:3] | passed | 0.00847 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:1:7:1] | passed | 0.00556 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:1:7:2] | passed | 0.00626 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:1:8:1] | passed | 0.00857 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:1:9:1] | passed | 0.0112 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:1:10:1] | passed | 0.01055 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:2:1] | passed | 0.00137 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:2:2] | passed | 0.00166 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:2:3] | passed | 0.00179 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:2:4] | passed | 0.00206 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:3:1] | passed | 0.00095 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:3:2] | passed | 0.00081 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:4:1] | passed | 0.00122 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb[1:1:4:2] | passed | 0.0014 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:1:1] | passed | 0.00817 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:1:2] | passed | 0.00788 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:1:3] | passed | 0.00762 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:2:1:1] | passed | 0.00699 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:2:1:2] | passed | 0.00787 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:2:2:1] | passed | 0.00755 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:2:2:2] | passed | 0.00897 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:2:3:1] | passed | 0.00991 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:2:3:2] | passed | 0.00789 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:2:3:3] | passed | 0.00748 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:3:1:1] | passed | 0.00655 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:3:1:2] | passed | 0.00618 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:3:1:3] | passed | 0.00665 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:3:2:1] | passed | 0.00861 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:3:2:2] | passed | 0.00722 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:3:3:1] | passed | 0.00718 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:3:3:2] | passed | 0.00797 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:3:3:3] | passed | 0.00796 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:4:1:1] | passed | 0.01082 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:4:2:1] | passed | 0.00843 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:5:1] | passed | 0.00328 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:5:2] | passed | 0.00283 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:5:3] | passed | 0.00235 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:6:1] | passed | 0.00941 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:6:2] | passed | 0.00971 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:6:3] | passed | 0.01086 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:7:1] | passed | 0.01143 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:7:2] | passed | 0.01384 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:7:3] | passed | 0.01613 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:8:1] | passed | 0.00137 seconds |
+./spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb[1:1:8:2] | passed | 0.00119 seconds |
+./spec/services/command_tower/secrets/cleanse_spec.rb[1:1:1] | passed | 0.00713 seconds |
+./spec/services/command_tower/secrets/cleanse_spec.rb[1:1:2:1] | passed | 0.00767 seconds |
+./spec/services/command_tower/secrets/cleanse_spec.rb[1:1:2:2:1] | passed | 0.01303 seconds |
+./spec/services/command_tower/secrets/cleanse_spec.rb[1:1:3:1] | passed | 0.00866 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:1:1:1] | passed | 0.00701 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:1:2:1] | passed | 0.01713 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:1:2:2] | passed | 0.01867 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:1:2:3:1] | passed | 0.01676 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:1:2:3:2] | passed | 0.02002 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:1:3:1] | passed | 0.00769 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:2:1:1] | passed | 0.00637 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:2:2:1] | passed | 0.01551 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:2:2:2] | passed | 0.01833 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:2:2:3:1] | passed | 0.01912 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:2:2:3:2] | passed | 0.03003 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:2:3:1] | passed | 0.0094 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:3:1] | passed | 0.00547 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:3:2:1] | passed | 0.00638 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:3:3:1] | passed | 0.01855 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:3:3:2] | passed | 0.01603 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:3:3:3:1] | passed | 0.01828 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:3:3:3:2] | passed | 0.01906 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:1:3:4:1] | passed | 0.00839 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:1:1:1] | passed | 0.00588 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:1:2:1] | passed | 0.02582 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:1:2:2] | passed | 0.01686 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:1:2:3:1] | passed | 0.01667 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:1:2:3:2] | passed | 0.01969 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:1:3:1] | passed | 0.00593 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:2:1:1] | passed | 0.00629 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:2:2:1] | passed | 0.01601 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:2:2:2] | passed | 0.01763 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:2:2:3:1] | passed | 0.01693 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:2:2:3:2] | passed | 0.02101 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:2:3:1] | passed | 0.00739 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:3:1] | passed | 0.00704 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:3:2:1] | passed | 0.00781 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:3:3:1] | passed | 0.02237 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:3:3:2] | passed | 0.01901 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:3:3:3:1] | passed | 0.01933 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:3:3:3:2] | passed | 0.02 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:2:3:4:1] | passed | 0.00682 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:1:1:1] | passed | 0.0082 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:1:2:1] | passed | 0.01992 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:1:2:2] | passed | 0.02237 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:1:2:3:1] | passed | 0.01986 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:1:2:3:2] | passed | 0.02256 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:1:3:1] | passed | 0.0085 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:2:1:1] | passed | 0.00564 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:2:2:1] | passed | 0.02004 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:2:2:2] | passed | 0.02403 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:2:2:3:1] | passed | 0.02948 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:2:2:3:2] | passed | 0.01789 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:2:3:1] | passed | 0.00648 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:3:1] | passed | 0.00563 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:3:2:1] | passed | 0.01775 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:3:3:1] | passed | 0.0208 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:3:3:2] | passed | 0.02182 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:3:3:3:1] | passed | 0.02029 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:3:3:3:2] | passed | 0.01908 seconds |
+./spec/services/command_tower/secrets/generate_spec.rb[1:1:3:3:4:1] | passed | 0.00713 seconds |
+./spec/services/command_tower/secrets/verify_spec.rb[1:1:1] | passed | 0.00712 seconds |
+./spec/services/command_tower/secrets/verify_spec.rb[1:1:2] | passed | 0.00766 seconds |
+./spec/services/command_tower/secrets/verify_spec.rb[1:1:3:1] | passed | 0.00247 seconds |
+./spec/services/command_tower/secrets/verify_spec.rb[1:1:3:2] | passed | 0.00219 seconds |
+./spec/services/command_tower/secrets/verify_spec.rb[1:1:3:3] | passed | 0.0016 seconds |
+./spec/services/command_tower/secrets/verify_spec.rb[1:1:4:1] | passed | 0.00889 seconds |
+./spec/services/command_tower/secrets/verify_spec.rb[1:1:4:2] | passed | 0.00897 seconds |
+./spec/services/command_tower/secrets/verify_spec.rb[1:1:4:3] | passed | 0.00847 seconds |
+./spec/services/command_tower/secrets/verify_spec.rb[1:1:4:4] | passed | 0.0089 seconds |
+./spec/services/command_tower/secrets/verify_spec.rb[1:1:4:5:1] | passed | 0.00818 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:1:1:1:1] | passed | 0.00156 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:1:1:2:1] | passed | 0.00317 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:1:1:2:2:1] | passed | 0.00285 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:1:2] | passed | 0.00582 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:2:1:1:1] | passed | 0.00119 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:2:1:2:1] | passed | 0.00165 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:2:1:2:2:1] | passed | 0.00136 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:2:2] | passed | 0.00111 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:3:1:1:1:1] | passed | 0.00354 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:3:1:1:2:1] | passed | 0.0018 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:3:1:2:1] | passed | 0.00111 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:3:1:3:1] | passed | 0.00139 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:3:1:3:2:1] | passed | 0.00106 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:3:2] | passed | 0.00197 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:4:1:1:1] | passed | 0.0012 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:4:1:2:1] | passed | 0.00136 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:4:1:3:1] | passed | 0.00222 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:4:1:3:2:1] | passed | 0.00112 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:4:2] | passed | 0.00165 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:5:1:1:1:1] | passed | 0.00131 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:5:1:1:2:1] | passed | 0.00136 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:5:1:2:1] | passed | 0.00154 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:5:1:3:1] | passed | 0.00248 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:5:1:3:2:1] | passed | 0.00126 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:5:2] | passed | 0.00148 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:1:1] | passed | 0.00148 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:1:2] | passed | 0.00167 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:1:3:1] | passed | 0.00174 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:1:3:2] | passed | 0.00216 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:1:4:1:1] | passed | 0.00178 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:1:4:2:1] | passed | 0.00139 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:1:4:2:2] | passed | 0.00151 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:1:4:3:1] | passed | 0.00187 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:1:4:3:2] | passed | 0.00216 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:1:4:4:1] | passed | 0.00197 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:1:4:4:2] | passed | 0.00151 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:2:1] | passed | 0.0012 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:2:2] | passed | 0.00167 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:2:3:1] | passed | 0.00132 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:2:4:1:1] | passed | 0.00189 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:2:4:2:1] | passed | 0.0016 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:2:4:2:2] | passed | 0.00179 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:2:4:3:1] | passed | 0.00166 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:2:4:3:2] | passed | 0.00213 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:2:4:4:1] | passed | 0.00206 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:2:4:4:2] | passed | 0.00261 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:3:1] | passed | 0.00112 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:3:2] | passed | 0.00107 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:3:3:1] | passed | 0.00232 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:3:4:1:1] | passed | 0.00163 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:3:4:2:1] | passed | 0.00101 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:3:4:2:2] | passed | 0.00105 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:3:4:3:1] | passed | 0.00165 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:3:4:3:2] | passed | 0.00155 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:3:4:4:1] | passed | 0.00144 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:3:4:4:2] | passed | 0.00195 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:4:1] | passed | 0.00621 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:4:2] | passed | 0.00775 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:4:3] | passed | 0.00521 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:4:4:1] | passed | 0.00174 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:4:5:1:1] | passed | 0.0021 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:4:5:2:1] | passed | 0.00153 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:4:5:2:2] | passed | 0.0015 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:4:5:3:1] | passed | 0.00194 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:4:5:3:2] | passed | 0.00233 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:4:5:4:1] | passed | 0.00383 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:4:5:4:2] | passed | 0.0022 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:5:1] | passed | 0.0014 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:5:2] | passed | 0.0014 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:5:3:1] | passed | 0.00189 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:5:4:1:1] | passed | 0.00135 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:5:4:2:1] | passed | 0.00156 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:5:4:2:2] | passed | 0.00112 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:5:4:3:1] | passed | 0.00477 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:5:4:3:2] | passed | 0.0038 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:5:4:4:1] | passed | 0.00165 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:6:5:4:4:2] | passed | 0.00153 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:7:1:1] | passed | 0.00192 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:7:1:2:1] | passed | 0.00226 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:7:1:2:2:1] | passed | 0.00412 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:7:1:3:1] | passed | 0.00282 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:7:1:3:2:1] | passed | 0.00154 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:1:1] | passed | 0.00078 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:2:1] | passed | 0.00192 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:2:2] | passed | 0.00137 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:2:3] | passed | 0.00237 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:2:4] | passed | 0.00155 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:3:1] | passed | 0.00209 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:3:2] | passed | 0.0013 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:3:3:1] | passed | 0.00096 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:3:4:1:1] | passed | 0.00323 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:3:4:1:2:1] | passed | 0.00139 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:3:4:2:1] | passed | 0.00124 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:3:4:2:2] | passed | 0.00114 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:3:4:2:3:1] | passed | 0.00123 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:3:4:2:3:2] | passed | 0.00107 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:3:4:3:1] | passed | 0.00121 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:3:4:3:2] | passed | 0.00136 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:3:4:3:3:1] | passed | 0.00192 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:3:4:3:3:2] | passed | 0.00123 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:4:1] | passed | 0.00154 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:4:2] | passed | 0.00157 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:4:3:1] | passed | 0.0014 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:4:4:1:1] | passed | 0.00273 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:4:4:1:2:1] | passed | 0.00307 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:4:4:2:1] | passed | 0.00254 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:4:4:2:2] | passed | 0.00213 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:4:4:2:3:1] | passed | 0.00142 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:4:4:2:3:2] | passed | 0.00224 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:4:4:3:1] | passed | 0.00147 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:4:4:3:2] | passed | 0.0027 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:4:4:3:3:1] | passed | 0.00238 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:4:4:3:3:2] | passed | 0.00119 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:5:1] | passed | 0.00318 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:5:2] | passed | 0.00164 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:5:3:1] | passed | 0.00111 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:5:4:1:1] | passed | 0.00156 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:5:4:1:2:1] | passed | 0.00136 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:5:4:2:1] | passed | 0.00134 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:5:4:2:2] | passed | 0.00184 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:5:4:2:3:1] | passed | 0.00109 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:5:4:2:3:2] | passed | 0.00128 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:5:4:3:1] | passed | 0.00156 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:5:4:3:2] | passed | 0.0018 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:5:4:3:3:1] | passed | 0.00104 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:5:4:3:3:2] | passed | 0.00093 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:6:1] | passed | 0.00158 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:6:2] | passed | 0.002 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:6:3:1] | passed | 0.00175 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:6:4:1:1] | passed | 0.00176 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:6:4:1:2:1] | passed | 0.00139 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:6:4:2:1] | passed | 0.00148 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:6:4:2:2] | passed | 0.00145 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:6:4:2:3:1] | passed | 0.0026 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:6:4:2:3:2] | passed | 0.00277 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:6:4:3:1] | passed | 0.00211 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:6:4:3:2] | passed | 0.00261 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:6:4:3:3:1] | passed | 0.00237 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:6:4:3:3:2] | passed | 0.00195 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:7:1] | passed | 0.0021 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:7:2] | passed | 0.00236 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:7:3:1] | passed | 0.00208 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:7:4:1:1] | passed | 0.00303 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:7:4:1:2:1] | passed | 0.00176 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:7:4:2:1] | passed | 0.00193 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:7:4:2:2] | passed | 0.00289 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:7:4:2:3:1] | passed | 0.00154 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:7:4:2:3:2] | passed | 0.00161 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:7:4:3:1] | passed | 0.00285 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:7:4:3:2] | passed | 0.0019 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:7:4:3:3:1] | passed | 0.00155 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:7:4:3:3:2] | passed | 0.00197 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:8:1:1] | passed | 0.00094 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:8:2:1] | passed | 0.00132 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:1:1] | passed | 0.00131 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:1:2] | passed | 0.00145 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:1:3:1] | passed | 0.00154 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:1:1] | passed | 0.0013 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:1:2:1] | passed | 0.00115 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:2:1] | passed | 0.00131 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:2:2] | passed | 0.00148 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:2:3:1] | passed | 0.00299 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:2:3:2] | passed | 0.00118 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:3:1] | passed | 0.00136 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:3:2] | passed | 0.00126 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:3:3:1] | passed | 0.00124 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:1:4:3:3:2] | passed | 0.00216 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:2] | passed | 0.00122 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:3] | passed | 0.00112 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:4:1] | passed | 0.00255 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:5:1:1] | passed | 0.00328 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:5:1:2:1] | passed | 0.00179 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:5:2:1] | passed | 0.00202 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:5:2:2] | passed | 0.00358 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:5:2:3:1] | passed | 0.00231 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:5:2:3:2] | passed | 0.00169 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:5:3:1] | passed | 0.00132 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:5:3:2] | passed | 0.00166 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:5:3:3:1] | passed | 0.00224 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:5:3:3:2] | passed | 0.00136 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:1] | passed | 0.0016 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:2] | passed | 0.00186 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:3:1] | passed | 0.00106 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:1:1] | passed | 0.00186 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:1:2:1] | passed | 0.00225 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:2:1] | passed | 0.00265 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:2:2] | passed | 0.00196 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:2:3:1] | passed | 0.00197 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:2:3:2] | passed | 0.00176 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:3:1] | passed | 0.00142 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:3:2] | passed | 0.00169 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:3:3:1] | passed | 0.00157 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:1:4:3:3:2] | passed | 0.00141 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:1] | passed | 0.00208 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:2] | passed | 0.00162 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:3:1] | passed | 0.0023 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:1:1] | passed | 0.00189 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:1:2:1] | passed | 0.00195 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:2:1] | passed | 0.00173 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:2:2] | passed | 0.002 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:2:3:1] | passed | 0.00159 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:2:3:2] | passed | 0.00184 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:3:1] | passed | 0.0042 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:3:2] | passed | 0.00159 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:3:3:1] | passed | 0.00128 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:2:4:3:3:2] | passed | 0.0018 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:1] | passed | 0.00135 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:2] | passed | 0.00324 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:3:1] | passed | 0.00145 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:1:1] | passed | 0.00249 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:1:2:1] | passed | 0.0017 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:2:1] | passed | 0.00209 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:2:2] | passed | 0.00167 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:2:3:1] | passed | 0.00199 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:2:3:2] | passed | 0.00306 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:3:1] | passed | 0.00153 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:3:2] | passed | 0.01347 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:3:3:1] | passed | 0.002 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:3:4:3:3:2] | passed | 0.00126 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:1] | passed | 0.00145 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:2] | passed | 0.00166 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:3:1] | passed | 0.00134 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:1:1] | passed | 0.00171 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:1:2:1] | passed | 0.00162 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:2:1] | passed | 0.00128 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:2:2] | passed | 0.00282 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:2:3:1] | passed | 0.00228 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:2:3:2] | passed | 0.00145 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:3:1] | passed | 0.0014 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:3:2] | passed | 0.00131 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:3:3:1] | passed | 0.00225 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:4:4:3:3:2] | passed | 0.00175 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:1] | passed | 0.0015 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:2] | passed | 0.00172 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:3:1] | passed | 0.00165 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:1:1] | passed | 0.00186 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:1:2:1] | passed | 0.00188 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:2:1] | passed | 0.00171 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:2:2] | passed | 0.00192 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:2:3:1] | passed | 0.00197 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:2:3:2] | passed | 0.00196 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:3:1] | passed | 0.00152 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:3:2] | passed | 0.00127 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:3:3:1] | passed | 0.00163 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:9:6:5:4:3:3:2] | passed | 0.0014 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:10:1] | passed | 0.0009 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:10:2] | passed | 0.00092 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:10:3:1] | passed | 0.00094 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:10:4:1:1] | passed | 0.00171 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:10:4:1:2:1] | passed | 0.00239 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:10:4:2:1] | passed | 0.00136 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:10:4:2:2] | passed | 0.00136 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:10:4:2:3:1] | passed | 0.00149 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:10:4:2:3:2] | passed | 0.00117 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:10:4:3:1] | passed | 0.00189 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:10:4:3:2] | passed | 0.0015 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:10:4:3:3:1] | passed | 0.00148 seconds |
+./spec/services/command_tower/service_base_spec.rb[1:8:10:4:3:3:2] | passed | 0.00108 seconds |
+./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:1:1] | passed | 0.01494 seconds |
+./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:1:2] | passed | 0.04315 seconds |
+./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:1:3:1] | passed | 0.01023 seconds |
+./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:1:3:2] | passed | 0.01663 seconds |
+./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:1:3:3] | passed | 0.02042 seconds |
+./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:2:1] | passed | 0.00762 seconds |
+./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:2:2] | passed | 0.00924 seconds |
+./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:2:3:1] | passed | 0.007 seconds |
+./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:2:3:2] | passed | 0.00527 seconds |
+./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:2:3:3] | passed | 0.00907 seconds |
+./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:3:1] | passed | 0.01675 seconds |
+./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:3:2] | passed | 0.01149 seconds |
+./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:3:3] | passed | 0.01965 seconds |
+./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:3:4:1] | passed | 0.00654 seconds |
+./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:3:4:2] | passed | 0.00616 seconds |
+./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:1:3:4:3] | passed | 0.00676 seconds |
+./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:2:1] | passed | 0.00766 seconds |
+./spec/services/command_tower/user_attributes/modify_spec.rb[1:1:2:2] | passed | 0.00767 seconds |
+./spec/services/command_tower/user_attributes/roles_spec.rb[1:1:1] | passed | 0.01144 seconds |
+./spec/services/command_tower/user_attributes/roles_spec.rb[1:1:2] | passed | 0.01105 seconds |
+./spec/services/command_tower/user_attributes/roles_spec.rb[1:1:3:1] | passed | 0.00796 seconds |
+./spec/services/command_tower/user_attributes/roles_spec.rb[1:1:3:2] | passed | 0.00846 seconds |
+./spec/services/command_tower/user_attributes/roles_spec.rb[1:1:3:3] | passed | 0.01013 seconds |
+./spec/services/command_tower/user_attributes/roles_spec.rb[1:1:4:1] | passed | 0.00967 seconds |
+./spec/services/command_tower/user_attributes/roles_spec.rb[1:1:4:2] | passed | 0.01037 seconds |
./spec/services/command_tower/username/available_spec.rb[1:1:1:1] | passed | 0.00107 seconds |
-./spec/services/command_tower/username/available_spec.rb[1:1:2:1] | passed | 0.00149 seconds |
-./spec/services/command_tower/username/available_spec.rb[1:1:3:1] | passed | 0.0024 seconds |
-./spec/services/command_tower/username/available_spec.rb[1:1:4:1] | passed | 0.00144 seconds |
-./spec/services/command_tower/username/available_spec.rb[1:1:4:2] | passed | 0.00128 seconds |
-./spec/services/command_tower/username/available_spec.rb[1:1:4:3:1] | passed | 0.00135 seconds |
-./spec/services/command_tower/username/available_spec.rb[1:1:4:3:2] | passed | 0.00171 seconds |
-./spec/services/command_tower/username/available_spec.rb[1:1:4:3:3] | passed | 0.00132 seconds |
-./spec/services/command_tower/username/available_spec.rb[1:1:5:1] | passed | 0.00718 seconds |
-./spec/services/command_tower/username/available_spec.rb[1:1:5:2] | passed | 0.00385 seconds |
-./spec/services/command_tower/username/available_spec.rb[1:1:5:3:1] | passed | 0.00389 seconds |
-./spec/services/command_tower/username/available_spec.rb[1:1:5:3:2] | passed | 0.00466 seconds |
-./spec/services/command_tower/username/available_spec.rb[1:1:5:3:3] | passed | 0.00404 seconds |
+./spec/services/command_tower/username/available_spec.rb[1:1:2:1] | passed | 0.0011 seconds |
+./spec/services/command_tower/username/available_spec.rb[1:1:3:1] | passed | 0.00153 seconds |
+./spec/services/command_tower/username/available_spec.rb[1:1:4:1] | passed | 0.00161 seconds |
+./spec/services/command_tower/username/available_spec.rb[1:1:4:2] | passed | 0.00156 seconds |
+./spec/services/command_tower/username/available_spec.rb[1:1:4:3:1] | passed | 0.0017 seconds |
+./spec/services/command_tower/username/available_spec.rb[1:1:4:3:2] | passed | 0.00146 seconds |
+./spec/services/command_tower/username/available_spec.rb[1:1:4:3:3] | passed | 0.00173 seconds |
+./spec/services/command_tower/username/available_spec.rb[1:1:5:1] | passed | 0.01017 seconds |
+./spec/services/command_tower/username/available_spec.rb[1:1:5:2] | passed | 0.00763 seconds |
+./spec/services/command_tower/username/available_spec.rb[1:1:5:3:1] | passed | 0.00755 seconds |
+./spec/services/command_tower/username/available_spec.rb[1:1:5:3:2] | passed | 0.00557 seconds |
+./spec/services/command_tower/username/available_spec.rb[1:1:5:3:3] | passed | 0.00643 seconds |
diff --git a/spec/integration_test/auth/cookie_auth_spec.rb b/spec/integration_test/auth/cookie_auth_spec.rb
new file mode 100644
index 0000000..3fe5c43
--- /dev/null
+++ b/spec/integration_test/auth/cookie_auth_spec.rb
@@ -0,0 +1,829 @@
+# frozen_string_literal: true
+
+RSpec.describe "Cookie Authentication", type: :controller do
+ let(:fake_user) { create(:user, password:) }
+ let(:password) { Faker::Alphanumeric.alpha(number: 20) }
+ let(:cookie_name) { "ct_jwt" }
+ let(:response_body) { JSON.parse(response.body) }
+
+ describe "when cookie auth is enabled" do
+ before do
+ CommandTower.configure do |config|
+ config.jwt.cookie.enabled = true
+ end
+ end
+
+ describe CommandTower::Auth::PlainTextController do
+ describe "POST /auth/login" do
+ subject(:login_post) { post(:login_post, params: login_params) }
+
+ let(:login_params) { { identifier: fake_user.username, password: } }
+ let(:set_cookie_header) { response.headers["Set-Cookie"] }
+ let(:reset_header) { response.headers["X-Authorization-Reset"] }
+ let(:expire_header) { response.headers["X-Authorization-Expire"] }
+
+ it "sets cookie when login is successful" do
+ login_post
+
+ expect(response.status).to eq(201)
+ expect(set_cookie_header).to be_present
+ # Handle both string and array
+ cookie_headers = set_cookie_header.is_a?(Array) ? set_cookie_header.join(" ") : set_cookie_header
+ expect(cookie_headers).to include(cookie_name)
+ # Find the JWT cookie specifically
+ jwt_cookie = set_cookie_header.is_a?(Array) ? set_cookie_header.find { |h| h.include?("#{cookie_name}=") } : set_cookie_header
+ expect(jwt_cookie).to match(/httponly/i) if jwt_cookie
+ expect(cookie_headers).to match(/samesite=lax/i)
+ expect(cookie_headers).to include(response_body["token"])
+ end
+
+ it "sets X-Authorization-Reset header with token" do
+ login_post
+
+ expect(response.status).to eq(201)
+ expect(reset_header).to eq(response_body["token"])
+ end
+
+ it "sets X-Authorization-Expire header" do
+ login_post
+
+ expect(response.status).to eq(201)
+ expect(expire_header).to be_present
+ expect(Time.parse(expire_header)).to be_within(1.second).of(CommandTower.config.jwt.ttl.from_now)
+ end
+ end
+ end
+
+ describe CommandTower::UserController do
+ describe "Cookie fallback authentication" do
+ let(:login_data) { get_login_token_and_cookie(user: fake_user, password:) }
+ let(:token) { login_data[:token] }
+ let(:cookie_value) { login_data[:cookie_value] }
+
+ before do
+ @request.cookies[cookie_name] = cookie_value
+ unset_jwt_token!
+ end
+
+ context "when Authorization header is missing" do
+ subject(:show_user) { get(:show) }
+
+ it "authenticates using cookie" do
+ show_user
+
+ expect(response.status).to eq(200)
+ end
+ end
+
+ context "when both Authorization header and cookie are present" do
+ subject(:show_user) { get(:show) }
+
+ let(:token2) { CommandTower::Jwt::LoginCreate.(user: fake_user).token }
+
+ before do
+ @request.cookies[cookie_name] = token
+ set_jwt_token!(user: fake_user, token: token2)
+ end
+
+ it "prefers Authorization header over cookie" do
+ show_user
+
+ expect(response.status).to eq(200)
+ end
+ end
+ end
+
+ describe "Token refresh updates cookie" do
+ let(:initial_login_data) { get_login_token_and_cookie(user: fake_user, password:) }
+ let(:initial_token) { initial_login_data[:token] }
+ let(:initial_cookie) { initial_login_data[:cookie_value] }
+
+ it "updates cookie when X-Authorization-Reset header is set" do
+ # Advance time before refresh to ensure new token has different generated_at
+ Timecop.freeze(Time.zone.now + 2.seconds)
+
+ set_jwt_token!(user: fake_user, token: initial_token, with_reset: true)
+ get(:show)
+
+ expect(response.status).to eq(200)
+
+ new_set_cookie_header = response.headers["Set-Cookie"]
+ expect(new_set_cookie_header).to be_present
+ # Handle both string and array
+ cookie_headers = new_set_cookie_header.is_a?(Array) ? new_set_cookie_header.join(" ") : new_set_cookie_header
+ expect(cookie_headers).to include(cookie_name)
+
+ new_token = response.headers["X-Authorization-Reset"]
+ expect(new_token).to be_present
+ # Token should be different due to new generated_at timestamp
+ # Note: If time is frozen at the same second, tokens may be identical
+ # In that case, we verify the cookie was still updated
+ if new_token != initial_token
+ # Find the JWT cookie from the header(s)
+ jwt_cookie = new_set_cookie_header.is_a?(Array) ? new_set_cookie_header.find { |h| h.include?("#{cookie_name}=") } : new_set_cookie_header
+ new_cookie = jwt_cookie.match(/#{cookie_name}=([^;]+)/)[1]
+ expect(new_cookie).to eq(new_token)
+ expect(new_cookie).not_to eq(initial_cookie)
+ else
+ # If token is same (time frozen at same second), at least verify cookie header is present
+ expect(cookie_headers).to include(cookie_name)
+ end
+ end
+ end
+
+ describe "Cookie invalidation on authentication failure" do
+ subject(:show_user) { get(:show) }
+
+ context "when invalid cookie token is present" do
+ before do
+ @request.cookies[cookie_name] = "invalid_token_value"
+ unset_jwt_token!
+ end
+
+ it "returns 401 status" do
+ show_user
+
+ expect(response.status).to eq(401)
+ end
+
+ it "clears the invalid cookie in response" do
+ show_user
+
+ clear_cookie_header = response.headers["Set-Cookie"]
+ expect(clear_cookie_header).to be_present
+ expect(clear_cookie_header).to include("#{cookie_name}=")
+ expect(clear_cookie_header).to match(/expires=[^;]+/i)
+ end
+ end
+
+ context "when invalid Authorization header is present (cookie also present)" do
+ before do
+ # Set a valid cookie
+ login_data = get_login_token_and_cookie(user: fake_user, password:)
+ @request.cookies[cookie_name] = login_data[:cookie_value]
+ # Set invalid Authorization header
+ @request.headers[CommandTower::ApplicationController::AUTHENTICATION_HEADER] = "Bearer invalid_token"
+ end
+
+ it "returns 401 status" do
+ show_user
+
+ expect(response.status).to eq(401)
+ end
+
+ it "does NOT clear cookie when Authorization header is invalid" do
+ show_user
+
+ # Cookie should not be cleared because token source was :header, not :cookie
+ clear_cookie_header = response.headers["Set-Cookie"]
+ expect(clear_cookie_header).to be_nil
+ end
+ end
+
+ context "when no token is present (missing token)" do
+ before do
+ unset_jwt_token!
+ end
+
+ it "returns 401 status" do
+ show_user
+
+ expect(response.status).to eq(401)
+ end
+
+ it "does not set cookie clear header when no token is present" do
+ show_user
+
+ clear_cookie_header = response.headers["Set-Cookie"]
+ expect(clear_cookie_header).to be_nil
+ end
+ end
+
+ context "when expired cookie token is present" do
+ before do
+ # Create a token and then expire it by manipulating the user's verifier_token
+ login_data = get_login_token_and_cookie(user: fake_user, password:)
+ old_token = login_data[:token]
+ # Change user's verifier_token to invalidate the token
+ fake_user.update!(verifier_token: SecureRandom.hex(32))
+ @request.cookies[cookie_name] = old_token
+ unset_jwt_token!
+ end
+
+ it "returns 401 status" do
+ show_user
+
+ expect(response.status).to eq(401)
+ end
+
+ it "clears the expired cookie in response" do
+ show_user
+
+ clear_cookie_header = response.headers["Set-Cookie"]
+ expect(clear_cookie_header).to be_present
+ expect(clear_cookie_header).to include("#{cookie_name}=")
+ expect(clear_cookie_header).to match(/expires=[^;]+/i)
+ end
+ end
+
+ context "when email validation is required (412 status)" do
+ let(:unvalidated_user) { create(:user, :unvalidated_email, password:, created_at: 5.minutes.ago) }
+ let(:login_data) { get_login_token_and_cookie(user: unvalidated_user, password:) }
+ let(:cookie_value) { login_data[:cookie_value] }
+
+ before do
+ CommandTower.configure do |config|
+ config.login.plain_text.email_verify.enable = true
+ end
+ @request.cookies[cookie_name] = cookie_value
+ unset_jwt_token!
+ end
+
+ it "returns 412 status" do
+ show_user
+
+ expect(response.status).to eq(412)
+ end
+
+ it "does NOT clear cookie when email validation is required (412 status)" do
+ show_user
+
+ # Cookie should NOT be cleared for 412 status - user needs cookie to verify email
+ clear_cookie_header = response.headers["Set-Cookie"]
+ expect(clear_cookie_header).to be_nil
+ end
+
+ it "returns email validation error schema" do
+ show_user
+
+ response_body = JSON.parse(response.body)
+ expect(response_body["message"]).to eq("Email must be verified to continue")
+ # Meta field may or may not be present depending on schema implementation
+ # The important thing is that cookie is NOT cleared (tested above)
+ end
+ end
+ end
+ end
+
+ describe CommandTower::Auth::LogoutController do
+ describe "POST /auth/logout" do
+ subject(:logout_post) { post(:logout_post) }
+
+ let(:login_data) { get_login_token_and_cookie(user: fake_user, password:) }
+ let(:cookie_value) { login_data[:cookie_value] }
+ let(:clear_cookie_header) { response.headers["Set-Cookie"] }
+
+ context "when cookie is set" do
+ before do
+ @request.cookies[cookie_name] = cookie_value
+ unset_jwt_token!
+ end
+
+ it "clears JWT cookie when logout is called" do
+ logout_post
+
+ expect(response.status).to eq(200)
+ expect(clear_cookie_header).to be_present
+ # Handle both string and array (when CSRF cookie is also cleared)
+ cookie_headers = clear_cookie_header.is_a?(Array) ? clear_cookie_header.join(" ") : clear_cookie_header
+ expect(cookie_headers).to include("#{cookie_name}=")
+ expect(cookie_headers).to match(/expires=[^;]+/i)
+ end
+ end
+
+ context "when both JWT cookie and CSRF cookie are set" do
+ let(:csrf_token) { CommandTower::Jwt::CsrfHelper.generate_token }
+
+ before do
+ CommandTower.configure do |config|
+ config.jwt.cookie.enabled = true
+ config.jwt.cookie.csrf.enabled = true
+ end
+ @request.cookies[cookie_name] = cookie_value
+ set_csrf_cookie!(csrf_token)
+ unset_jwt_token!
+ end
+
+ it "clears both JWT cookie and CSRF cookie" do
+ logout_post
+
+ expect(response.status).to eq(200)
+ expect(clear_cookie_header).to be_present
+ # Handle both string and array
+ cookie_headers = clear_cookie_header.is_a?(Array) ? clear_cookie_header : [clear_cookie_header]
+
+ # Verify JWT cookie is cleared
+ jwt_cookie = cookie_headers.find { |h| h.include?("#{cookie_name}=") }
+ expect(jwt_cookie).to be_present
+ expect(jwt_cookie).to match(/expires=[^;]+/i)
+
+ # Verify CSRF cookie is cleared
+ csrf_cookie = cookie_headers.find { |h| h.include?("ct_csrf=") }
+ expect(csrf_cookie).to be_present
+ expect(csrf_cookie).to match(/max-age=0/i)
+ end
+ end
+
+ it "returns success message" do
+ logout_post
+
+ expect(response.status).to eq(200)
+ expect(response_body["message"]).to eq("Logged out")
+ end
+ end
+ end
+
+ describe CommandTower::UserController do
+ describe "CSRF protection for cookie-authenticated requests" do
+ let(:login_data) { get_login_token_and_cookie(user: fake_user, password:) }
+ let(:cookie_value) { login_data[:cookie_value] }
+ let(:csrf_cookie_name) { CommandTower.config.jwt.cookie.csrf.cookie_name }
+ let(:csrf_header_name) { CommandTower.config.jwt.cookie.csrf.header_name }
+
+ before do
+ CommandTower.configure do |config|
+ config.jwt.cookie.enabled = true
+ config.jwt.cookie.csrf.enabled = true
+ end
+ @request.cookies[cookie_name] = cookie_value
+ unset_jwt_token!
+ end
+
+ context "when CSRF is required (cookie-auth unsafe method)" do
+ let(:csrf_token) { CommandTower::Jwt::CsrfHelper.generate_token }
+
+ context "with missing CSRF header" do
+ before do
+ set_csrf_cookie!(csrf_token)
+ end
+
+ subject(:post_request) { post(:modify, params: {}) }
+
+ it "returns 403 status" do
+ post_request
+ expect(response.status).to eq(403)
+ end
+
+ it "returns csrf_missing error message" do
+ post_request
+ expect(JSON.parse(response.body)["message"]).to eq("csrf_missing")
+ end
+ end
+
+ context "with missing CSRF cookie" do
+ before do
+ set_csrf_header!(csrf_token)
+ end
+
+ subject(:post_request) { post(:modify, params: {}) }
+
+ it "returns 403 status" do
+ post_request
+ expect(response.status).to eq(403)
+ end
+
+ it "returns csrf_missing error message" do
+ post_request
+ expect(JSON.parse(response.body)["message"]).to eq("csrf_missing")
+ end
+ end
+
+ context "with mismatched CSRF tokens" do
+ let(:cookie_token) { CommandTower::Jwt::CsrfHelper.generate_token }
+ let(:header_token) { CommandTower::Jwt::CsrfHelper.generate_token }
+
+ before do
+ set_csrf_cookie!(cookie_token)
+ set_csrf_header!(header_token)
+ end
+
+ subject(:post_request) { post(:modify, params: {}) }
+
+ it "returns 403 status" do
+ post_request
+ expect(response.status).to eq(403)
+ end
+
+ it "returns csrf_mismatch error message" do
+ post_request
+ expect(JSON.parse(response.body)["message"]).to eq("csrf_mismatch")
+ end
+ end
+
+ context "with matching CSRF tokens" do
+ let(:csrf_token) { CommandTower::Jwt::CsrfHelper.generate_token }
+
+ before do
+ set_csrf_cookie!(csrf_token)
+ set_csrf_header!(csrf_token)
+ end
+
+ subject(:post_request) { post(:modify, params: { username: fake_user.username }) }
+
+ it "succeeds" do
+ post_request
+ expect(response.status).to eq(201)
+ end
+ end
+ end
+
+ context "when CSRF is not required" do
+ context "with Authorization header (header-auth)" do
+ let(:token) { CommandTower::Jwt::LoginCreate.(user: fake_user).token }
+
+ before do
+ set_jwt_token!(user: fake_user, token:)
+ @request.cookies.delete(cookie_name)
+ end
+
+ subject(:post_request) { post(:modify, params: { username: fake_user.username }) }
+
+ it "succeeds without CSRF token" do
+ post_request
+ expect(response.status).to eq(201)
+ end
+ end
+
+ context "with GET request" do
+ subject(:get_request) { get(:show) }
+
+ it "succeeds without CSRF token" do
+ get_request
+ expect(response.status).to eq(200)
+ end
+ end
+ end
+ end
+
+ describe "CSRF cookie issuance and rotation" do
+ let(:login_data) { get_login_token_and_cookie(user: fake_user, password:) }
+ let(:cookie_value) { login_data[:cookie_value] }
+
+ before do
+ CommandTower.configure do |config|
+ config.jwt.cookie.enabled = true
+ config.jwt.cookie.csrf.enabled = true
+ end
+ end
+
+ context "on login" do
+ context "when rotate_on_login is true" do
+ before do
+ CommandTower.configure do |config|
+ config.jwt.cookie.csrf.rotate_on_login = true
+ end
+ end
+
+ describe CommandTower::Auth::PlainTextController do
+ subject(:login_post) { post(:login_post, params: { identifier: fake_user.username, password: }) }
+
+ it "sets CSRF cookie" do
+ login_post
+ csrf_cookie = extract_csrf_cookie(response)
+ expect(csrf_cookie).to be_present
+ end
+
+ it "sets CSRF cookie with non-HttpOnly flag" do
+ login_post
+ set_cookie_header = response.headers["Set-Cookie"]
+ # Handle both string and array
+ cookie_headers = set_cookie_header.is_a?(Array) ? set_cookie_header.join(" ") : set_cookie_header
+ expect(cookie_headers).to match(/ct_csrf=/)
+ # Check that ct_csrf cookie specifically doesn't have httponly
+ csrf_cookie = set_cookie_header.is_a?(Array) ? set_cookie_header.find { |h| h.include?("ct_csrf=") } : set_cookie_header
+ expect(csrf_cookie).not_to match(/httponly/i) if csrf_cookie
+ end
+ end
+ end
+
+ context "when rotate_on_login is false" do
+ before do
+ CommandTower.configure do |config|
+ config.jwt.cookie.csrf.rotate_on_login = false
+ end
+ end
+
+ describe CommandTower::Auth::PlainTextController do
+ subject(:login_post) { post(:login_post, params: { identifier: fake_user.username, password: }) }
+
+ it "creates CSRF cookie when missing (rotate_on_login=false means don't force rotation, but ensure exists)" do
+ login_post
+ csrf_cookie = extract_csrf_cookie(response)
+ expect(csrf_cookie).to be_present
+ end
+
+ context "when CSRF cookie already exists" do
+ let(:existing_csrf_token) { CommandTower::Jwt::CsrfHelper.generate_token }
+
+ before do
+ set_csrf_cookie!(existing_csrf_token)
+ end
+
+ it "does not set CSRF cookie in response (no rotation, cookie already exists)" do
+ login_post
+ # When cookie exists and rotate_on_login=false, ensure_cookie does nothing
+ # Expect NO Set-Cookie header for CSRF (cookie exists, not rotated)
+ csrf_cookie = extract_csrf_cookie(response)
+ expect(csrf_cookie).to be_nil
+ end
+
+ it "keeps existing CSRF cookie value in request" do
+ login_post
+ # Cookie should still exist in request (not cleared)
+ expect(get_csrf_cookie_value).to eq(existing_csrf_token)
+ end
+ end
+
+ context "when CSRF cookie does not exist" do
+ it "creates CSRF cookie (ensures it exists)" do
+ login_post
+ csrf_cookie = extract_csrf_cookie(response)
+ expect(csrf_cookie).to be_present
+ end
+ end
+ end
+ end
+ end
+
+ context "on token reset" do
+ let(:initial_login_data) { get_login_token_and_cookie(user: fake_user, password:) }
+ let(:initial_token) { initial_login_data[:token] }
+
+ before do
+ @request.cookies[cookie_name] = initial_login_data[:cookie_value]
+ unset_jwt_token!
+ end
+
+ context "when rotate_on_reset is true" do
+ before do
+ CommandTower.configure do |config|
+ config.jwt.cookie.csrf.rotate_on_reset = true
+ end
+ Timecop.freeze(Time.zone.now + 2.seconds)
+ set_jwt_token!(user: fake_user, token: initial_token, with_reset: true)
+ end
+
+ subject(:get_request) { get(:show) }
+
+ it "rotates CSRF cookie" do
+ get_request
+ csrf_cookie = extract_csrf_cookie(response)
+ expect(csrf_cookie).to be_present
+ end
+ end
+ end
+
+ context "on logout" do
+ let(:login_data) { get_login_token_and_cookie(user: fake_user, password:) }
+ let(:csrf_token) { CommandTower::Jwt::CsrfHelper.generate_token }
+
+ before do
+ @request.cookies[cookie_name] = login_data[:cookie_value]
+ set_csrf_cookie!(csrf_token)
+ unset_jwt_token!
+ end
+
+ describe CommandTower::Auth::LogoutController do
+ subject(:logout_post) { post(:logout_post) }
+
+ it "clears CSRF cookie" do
+ logout_post
+ clear_cookie_header = response.headers["Set-Cookie"]
+ # Handle both string and array
+ cookie_headers = clear_cookie_header.is_a?(Array) ? clear_cookie_header.join(" ") : clear_cookie_header
+ expect(cookie_headers).to match(/ct_csrf=/)
+ expect(cookie_headers).to match(/max-age=0/i)
+ end
+ end
+ end
+ end
+
+ describe "CSRF disabled behavior" do
+ let(:login_data) { get_login_token_and_cookie(user: fake_user, password:) }
+ let(:cookie_value) { login_data[:cookie_value] }
+
+ before do
+ CommandTower.configure do |config|
+ config.jwt.cookie.enabled = true
+ config.jwt.cookie.csrf.enabled = false
+ end
+ @request.cookies[cookie_name] = cookie_value
+ unset_jwt_token!
+ end
+
+ context "with cookie-authenticated POST request" do
+ subject(:post_request) { post(:modify, params: { username: fake_user.username }) }
+
+ it "succeeds without CSRF token" do
+ post_request
+ expect(response.status).to eq(201)
+ end
+ end
+ end
+ end
+
+ end
+
+ describe CommandTower::UserController do
+ describe "Header-only authentication (legacy workflow)" do
+ let(:token) { CommandTower::Jwt::LoginCreate.(user: fake_user).token }
+
+ before do
+ set_jwt_token!(user: fake_user, token:)
+ # Explicitly remove cookies to ensure header-only auth
+ @request.cookies.delete(cookie_name) if @request.cookies.key?(cookie_name)
+ end
+
+ context "when cookies are disabled (legacy mode)" do
+ before do
+ CommandTower.configure do |config|
+ config.jwt.cookie.enabled = false
+ end
+ end
+
+ it "succeeds with GET request" do
+ get(:show)
+ expect(response.status).to eq(200)
+ end
+
+ it "succeeds with POST request" do
+ post(:modify, params: { username: fake_user.username })
+ expect(response.status).to eq(201)
+ end
+
+ it "succeeds with token refresh" do
+ set_jwt_token!(user: fake_user, token:, with_reset: true)
+ get(:show)
+ expect(response.status).to eq(200)
+ expect(response.headers["X-Authorization-Reset"]).to be_present
+ end
+ end
+
+ context "when cookies are enabled but CSRF is disabled" do
+ before do
+ CommandTower.configure do |config|
+ config.jwt.cookie.enabled = true
+ config.jwt.cookie.csrf.enabled = false
+ end
+ end
+
+ it "succeeds with GET request" do
+ get(:show)
+ expect(response.status).to eq(200)
+ end
+
+ it "succeeds with POST request without CSRF token" do
+ post(:modify, params: { username: fake_user.username })
+ expect(response.status).to eq(201)
+ end
+
+ it "succeeds with token refresh" do
+ set_jwt_token!(user: fake_user, token:, with_reset: true)
+ get(:show)
+ expect(response.status).to eq(200)
+ expect(response.headers["X-Authorization-Reset"]).to be_present
+ end
+ end
+
+ context "when cookies and CSRF are both enabled" do
+ before do
+ CommandTower.configure do |config|
+ config.jwt.cookie.enabled = true
+ config.jwt.cookie.csrf.enabled = true
+ end
+ end
+
+ it "succeeds with GET request" do
+ get(:show)
+ expect(response.status).to eq(200)
+ end
+
+ it "succeeds with POST request without CSRF token" do
+ post(:modify, params: { username: fake_user.username })
+ expect(response.status).to eq(201)
+ end
+
+ it "succeeds with token refresh" do
+ set_jwt_token!(user: fake_user, token:, with_reset: true)
+ get(:show)
+ expect(response.status).to eq(200)
+ expect(response.headers["X-Authorization-Reset"]).to be_present
+ end
+ end
+
+ context "when cookies and CSRF are enabled AND cookie is present (header takes precedence)" do
+ let(:login_data) { get_login_token_and_cookie(user: fake_user, password:) }
+ let(:cookie_value) { login_data[:cookie_value] }
+
+ before do
+ CommandTower.configure do |config|
+ config.jwt.cookie.enabled = true
+ config.jwt.cookie.csrf.enabled = true
+ end
+ # Set cookie in request but header should still take precedence
+ @request.cookies[cookie_name] = cookie_value
+ # Also set CSRF cookie to ensure header auth bypasses CSRF
+ csrf_token = CommandTower::Jwt::CsrfHelper.generate_token
+ set_csrf_cookie!(csrf_token)
+ end
+
+ it "succeeds with GET request (header takes precedence over cookie)" do
+ get(:show)
+ expect(response.status).to eq(200)
+ end
+
+ it "succeeds with POST request without CSRF token (header auth exempt from CSRF)" do
+ post(:modify, params: { username: fake_user.username })
+ expect(response.status).to eq(201)
+ end
+
+ it "succeeds even when CSRF cookie and header are mismatched (header auth exempt)" do
+ # Set mismatched CSRF tokens to prove header auth bypasses CSRF
+ set_csrf_cookie!(CommandTower::Jwt::CsrfHelper.generate_token)
+ set_csrf_header!(CommandTower::Jwt::CsrfHelper.generate_token)
+ post(:modify, params: { username: fake_user.username })
+ expect(response.status).to eq(201)
+ end
+
+ it "succeeds with token refresh" do
+ set_jwt_token!(user: fake_user, token:, with_reset: true)
+ get(:show)
+ expect(response.status).to eq(200)
+ expect(response.headers["X-Authorization-Reset"]).to be_present
+ end
+ end
+ end
+ end
+
+ describe "when cookie auth is disabled" do
+ before do
+ CommandTower.configure do |config|
+ config.jwt.cookie.enabled = false
+ end
+ end
+
+ describe CommandTower::Auth::PlainTextController do
+ describe "POST /auth/login" do
+ subject(:login_post) { post(:login_post, params: { identifier: fake_user.username, password: }) }
+
+ let(:set_cookie_header) { response.headers["Set-Cookie"] }
+
+ it "does not set cookie on login" do
+ login_post
+
+ expect(response.status).to eq(201)
+ expect(set_cookie_header).to be_nil
+ end
+ end
+ end
+
+ describe CommandTower::UserController do
+ describe "Cookie fallback authentication" do
+ subject(:show_user) { get(:show) }
+
+ let(:token) { CommandTower::Jwt::LoginCreate.(user: fake_user).token }
+
+ before do
+ @request.cookies[cookie_name] = token
+ unset_jwt_token!
+ end
+
+ it "does not use cookie for authentication fallback" do
+ show_user
+
+ expect(response.status).to eq(401)
+ end
+ end
+
+ describe "Token refresh" do
+ subject(:show_user_with_reset) do
+ set_jwt_token!(user: fake_user, token:, with_reset: true)
+ get(:show)
+ end
+
+ let(:login_data) { get_login_token_and_cookie(user: fake_user, password:) }
+ let(:token) { login_data[:token] }
+ let(:set_cookie_header) { response.headers["Set-Cookie"] }
+
+ it "does not update cookie on token refresh" do
+ show_user_with_reset
+
+ expect(set_cookie_header).to be_nil
+ end
+ end
+ end
+
+ describe CommandTower::Auth::LogoutController do
+ describe "POST /auth/logout" do
+ subject(:logout_post) { post(:logout_post) }
+
+ it "logout still works (no-op when cookies disabled)" do
+ logout_post
+
+ expect(response.status).to eq(200)
+ expect(response_body["message"]).to eq("Logged out")
+ end
+ end
+ end
+ end
+end
diff --git a/spec/integration_test/auth/password_reset/integration_spec.rb b/spec/integration_test/auth/password_reset/integration_spec.rb
new file mode 100644
index 0000000..e660102
--- /dev/null
+++ b/spec/integration_test/auth/password_reset/integration_spec.rb
@@ -0,0 +1,162 @@
+# frozen_string_literal: true
+
+RSpec.describe CommandTower::Auth::PlainTextController, type: :controller do
+ let(:fake_user) { build(:user, password:) }
+ let(:password) { Faker::Alphanumeric.alpha(number: 20) }
+ let(:new_password) { Faker::Alphanumeric.alpha(number: 20) }
+ let(:email) { fake_user.email }
+
+ it "complete password reset workflow" do
+ ####
+ # Create a new user
+ post(:create_post, params: {
+ first_name: fake_user.first_name,
+ last_name: fake_user.last_name,
+ username: fake_user.username,
+ email: fake_user.email,
+ password: password,
+ password_confirmation: password,
+ })
+ expect(response.status).to eq(201)
+
+ user = User.find_by(email: email)
+
+ ####
+ # Request password reset email
+ post(:password_forgot_send_post, params: { email: email })
+ expect(response.status).to eq(200)
+ expect(JSON.parse(response.body)["message"]).to eq("If an account exists with that email, a password reset link has been sent.")
+
+ ####
+ # Verify email was sent
+ expect(ActionMailer::Base.deliveries.count).to eq(1)
+
+ ####
+ # Get the reset token from user_secrets
+ user_secret = UserSecret.where(user: user, reason: CommandTower::Secrets::PASSWORD_RESET).last
+ expect(user_secret).to be_present
+ token = user_secret.secret
+
+ ####
+ # Validate the token
+ post(:password_forgot_validate_post, params: { token: token })
+ expect(response.status).to eq(200)
+ validate_response = JSON.parse(response.body)
+ expect(validate_response["valid"]).to eq(true)
+ expect(validate_response["expires_at"]).to be_present
+
+ ####
+ # Reset password with token
+ post(:password_forgot_reset_post, params: {
+ token: token,
+ password: new_password,
+ password_confirmation: new_password,
+ })
+ expect(response.status).to eq(200)
+ expect(JSON.parse(response.body)["message"]).to eq("Password has been successfully reset")
+
+ ####
+ # Verify old password no longer works
+ post(:login_post, params: {
+ identifier: email,
+ password: password,
+ })
+ expect(response.status).to eq(401)
+
+ ####
+ # Verify new password works
+ post(:login_post, params: {
+ identifier: email,
+ password: new_password,
+ })
+ expect(response.status).to eq(201)
+ login_response = JSON.parse(response.body)
+ expect(login_response["token"]).to be_present
+
+ ####
+ # Verify token cannot be reused
+ post(:password_forgot_reset_post, params: {
+ token: token,
+ password: "another_password123",
+ password_confirmation: "another_password123",
+ })
+ expect(response.status).to eq(401)
+ expect(JSON.parse(response.body)["message"]).to eq("Invalid token")
+ end
+
+ it "password reset with non-existent email returns 200" do
+ ####
+ # Request password reset for non-existent email
+ post(:password_forgot_send_post, params: { email: "nonexistent@example.com" })
+ expect(response.status).to eq(200)
+ expect(JSON.parse(response.body)["message"]).to eq("If an account exists with that email, a password reset link has been sent.")
+
+ ####
+ # Verify no email was sent
+ expect(ActionMailer::Base.deliveries.count).to eq(0)
+ end
+
+ it "password reset with invalid token returns 401" do
+ ####
+ # Try to validate invalid token
+ post(:password_forgot_validate_post, params: { token: "invalid_token_12345" })
+ expect(response.status).to eq(401)
+ expect(JSON.parse(response.body)["message"]).to eq("Invalid token")
+
+ ####
+ # Try to reset password with invalid token
+ post(:password_forgot_reset_post, params: {
+ token: "invalid_token_12345",
+ password: "new_password123",
+ password_confirmation: "new_password123",
+ })
+ expect(response.status).to eq(401)
+ expect(JSON.parse(response.body)["message"]).to eq("Invalid token")
+ end
+
+ it "password reset with expired token returns 401" do
+ ####
+ # Create a user
+ post(:create_post, params: {
+ first_name: fake_user.first_name,
+ last_name: fake_user.last_name,
+ username: fake_user.username,
+ email: fake_user.email,
+ password: password,
+ password_confirmation: password,
+ })
+ user = User.find_by(email: email)
+
+ ####
+ # Generate an expired token
+ result = CommandTower::Secrets::Generate.(
+ user: user,
+ secret_length: 32,
+ reason: CommandTower::Secrets::PASSWORD_RESET,
+ use_count_max: 1,
+ death_time: 1.hour,
+ type: CommandTower::Secrets::ALPHANUMERIC,
+ cleanse: false
+ )
+ expired_token = result.secret
+ # Manually expire the token by setting death_time in the past
+ user_secret = UserSecret.find_by(secret: expired_token)
+ user_secret.update!(death_time: 1.hour.ago)
+
+ ####
+ # Try to validate expired token
+ post(:password_forgot_validate_post, params: { token: expired_token })
+ expect(response.status).to eq(401)
+ expect(JSON.parse(response.body)["message"]).to eq("Invalid token")
+
+ ####
+ # Try to reset password with expired token
+ post(:password_forgot_reset_post, params: {
+ token: expired_token,
+ password: "new_password123",
+ password_confirmation: "new_password123",
+ })
+ expect(response.status).to eq(401)
+ expect(JSON.parse(response.body)["message"]).to eq("Invalid token")
+ end
+end
diff --git a/spec/integration_test/auth/plain_text/integration_spec.rb b/spec/integration_test/auth/plain_text/integration_spec.rb
index 1fd3c16..ade9e76 100644
--- a/spec/integration_test/auth/plain_text/integration_spec.rb
+++ b/spec/integration_test/auth/plain_text/integration_spec.rb
@@ -35,16 +35,16 @@
expect(response.status).to eq(401)
####
- # Sign in via username and validate token
- post(:login_post, params: { username: fake_user.username, password: })
+ # Sign in via username (using identifier) and validate token
+ post(:login_post, params: { identifier: fake_user.username, password: })
expect(response.status).to eq(201)
login_post_response = JSON.parse(response.body)
login_post_jwt_username = CommandTower::Jwt::AuthenticateUser.(token: login_post_response["token"], bypass_email_validation: true)
expect(login_post_jwt_username.success?).to be(true)
####
- # Sign in via email and validate token
- post(:login_post, params: { email: fake_user.email, password: })
+ # Sign in via email (using identifier) and validate token
+ post(:login_post, params: { identifier: fake_user.email, password: })
expect(response.status).to eq(201)
login_post_response = JSON.parse(response.body)
login_post_jwt_email = CommandTower::Jwt::AuthenticateUser.(token: login_post_response["token"], bypass_email_validation: true)
diff --git a/spec/services/command_tower/admin_service/users_spec.rb b/spec/services/command_tower/admin_service/users_spec.rb
index 3f074d2..e514e95 100644
--- a/spec/services/command_tower/admin_service/users_spec.rb
+++ b/spec/services/command_tower/admin_service/users_spec.rb
@@ -12,7 +12,7 @@
end
it "sets metadata" do
- expect(call.schema).to be_a(CommandTower::Schema::Admin::Users)
+ expect(call.schema).to be_a(CommandTower::Schema::Shared::Admin::Users)
expect(call.schema.count).to eq(1)
expect(call.schema.users.length).to eq(1)
end
diff --git a/spec/services/command_tower/inbox_service/blast/metadata_spec.rb b/spec/services/command_tower/inbox_service/blast/metadata_spec.rb
index 1cc9eea..7c1f988 100644
--- a/spec/services/command_tower/inbox_service/blast/metadata_spec.rb
+++ b/spec/services/command_tower/inbox_service/blast/metadata_spec.rb
@@ -9,7 +9,7 @@
end
it "sets metadata_blast" do
- expect(call.metadata).to be_a(CommandTower::Schema::Inbox::MessageBlastMetadata)
+ expect(call.metadata).to be_a(CommandTower::Schema::Shared::Inbox::MessageBlastMetadata)
expect(call.metadata.count).to eq(0)
end
@@ -21,8 +21,8 @@
end
it "sets metadata_blast" do
- expect(call.metadata).to be_a(CommandTower::Schema::Inbox::MessageBlastMetadata)
- expect(call.metadata.entities).to all(be_a(CommandTower::Schema::Inbox::MessageBlastEntity))
+ expect(call.metadata).to be_a(CommandTower::Schema::Shared::Inbox::MessageBlastMetadata)
+ expect(call.metadata.entities).to all(be_a(CommandTower::Schema::Entities::Inbox::MessageBlastEntity))
expect(call.metadata.count).to eq(5)
end
end
diff --git a/spec/services/command_tower/inbox_service/blast/retrieve_spec.rb b/spec/services/command_tower/inbox_service/blast/retrieve_spec.rb
index 13c4331..cd85ca2 100644
--- a/spec/services/command_tower/inbox_service/blast/retrieve_spec.rb
+++ b/spec/services/command_tower/inbox_service/blast/retrieve_spec.rb
@@ -13,7 +13,7 @@
end
it "returns message_blast" do
- expect(call.message_blast).to be_a(CommandTower::Schema::Inbox::MessageBlastEntity)
+ expect(call.message_blast).to be_a(CommandTower::Schema::Entities::Inbox::MessageBlastEntity)
end
it "returns correct content" do
diff --git a/spec/services/command_tower/inbox_service/blast/upsert_spec.rb b/spec/services/command_tower/inbox_service/blast/upsert_spec.rb
index fcfb45c..343b39f 100644
--- a/spec/services/command_tower/inbox_service/blast/upsert_spec.rb
+++ b/spec/services/command_tower/inbox_service/blast/upsert_spec.rb
@@ -40,7 +40,7 @@
end
it "sets blast context" do
- expect(call.blast).to be_a(CommandTower::Schema::Inbox::BlastResponse)
+ expect(call.blast).to be_a(CommandTower::Schema::Inbox::Blast::Create::Response)
end
context "without existing_users" do
@@ -59,7 +59,7 @@
end
it "sets blast context" do
- expect(call.blast).to be_a(CommandTower::Schema::Inbox::BlastResponse)
+ expect(call.blast).to be_a(CommandTower::Schema::Inbox::Blast::Create::Response)
end
end
end
@@ -80,7 +80,7 @@
end
it "sets blast context" do
- expect(call.blast).to be_a(CommandTower::Schema::Inbox::BlastResponse)
+ expect(call.blast).to be_a(CommandTower::Schema::Inbox::Blast::Create::Response)
end
context "without existing_users" do
@@ -99,7 +99,7 @@
end
it "sets blast context" do
- expect(call.blast).to be_a(CommandTower::Schema::Inbox::BlastResponse)
+ expect(call.blast).to be_a(CommandTower::Schema::Inbox::Blast::Create::Response)
end
end
end
diff --git a/spec/services/command_tower/inbox_service/message/metadata_spec.rb b/spec/services/command_tower/inbox_service/message/metadata_spec.rb
index f2e33b1..59c74c1 100644
--- a/spec/services/command_tower/inbox_service/message/metadata_spec.rb
+++ b/spec/services/command_tower/inbox_service/message/metadata_spec.rb
@@ -13,7 +13,7 @@
end
it "sets empty metadata" do
- expect(call.metadata).to be_a(CommandTower::Schema::Inbox::Metadata)
+ expect(call.metadata).to be_a(CommandTower::Schema::Shared::Inbox::Metadata)
expect(call.metadata.count).to eq(0)
expect(call.metadata.entities).to eq([])
end
@@ -27,10 +27,21 @@
end
it "sets metadata" do
- expect(call.metadata).to be_a(CommandTower::Schema::Inbox::Metadata)
+ expect(call.metadata).to be_a(CommandTower::Schema::Shared::Inbox::Metadata)
expect(call.metadata.count).to eq(count)
end
+ it "includes created_at in entities" do
+ entities = call.metadata.entities
+ expect(entities.length).to eq(count)
+ entities.each do |entity|
+ expect(entity.created_at).to be_present
+ expect(entity.created_at).to be_a(String)
+ # Verify it's a valid ISO 8601 date string
+ expect { Time.iso8601(entity.created_at) }.not_to raise_error
+ end
+ end
+
context "with many" do
let(:count) { 10 }
@@ -39,7 +50,7 @@
end
it "sets metadata" do
- expect(call.metadata).to be_a(CommandTower::Schema::Inbox::Metadata)
+ expect(call.metadata).to be_a(CommandTower::Schema::Shared::Inbox::Metadata)
expect(call.metadata.count).to eq(count)
end
end
@@ -52,5 +63,137 @@
include_examples "Services Pagination examples", ::Message
end
+
+ context "with pagination next field" do
+ let(:pagination) { { limit:, page:, cursor: } }
+ let(:limit) { 10 }
+ let(:page) { nil }
+ let(:cursor) { nil }
+
+ context "when no records exist" do
+ let(:count) { 0 }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+
+ it "sets pagination.next to nil" do
+ expect(call.metadata.pagination).to be_present
+ expect(call.metadata.pagination.next).to be_a(JsonSchematize::EmptyValue)
+ end
+ end
+
+ context "when records exist" do
+ before { create_list(:message, total_count, user:) }
+ let(:total_count) { 25 }
+
+ context "when on first page with more pages available" do
+ let(:page) { 1 }
+ let(:limit) { 10 }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+
+ it "sets pagination.next to present" do
+ expect(call.metadata.pagination).to be_present
+ expect(call.metadata.pagination.next).to be_present
+ expect(call.metadata.pagination.next).to be_a(CommandTower::Schema::Shared::Page)
+ end
+
+ it "sets remaining_pages correctly" do
+ expect(call.metadata.pagination.remaining_pages).to be > 0
+ end
+ end
+
+ context "when on last page" do
+ let(:page) { 3 }
+ let(:limit) { 10 }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+
+ it "sets pagination.next to nil" do
+ expect(call.metadata.pagination).to be_present
+ expect(call.metadata.pagination.next).to be_a(JsonSchematize::EmptyValue)
+ end
+
+ it "sets remaining_pages to 0" do
+ expect(call.metadata.pagination.remaining_pages).to eq(0)
+ end
+ end
+
+ context "when cursor is exactly at limit" do
+ let(:cursor) { 20 }
+ let(:limit) { 5 }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+
+ it "sets pagination.next to nil" do
+ expect(call.metadata.pagination).to be_present
+ expect(call.metadata.pagination.next).to be_a(JsonSchematize::EmptyValue)
+ end
+ end
+
+ context "when cursor exceeds total count" do
+ let(:cursor) { 30 }
+ let(:limit) { 10 }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+
+ it "sets pagination.next to nil" do
+ expect(call.metadata.pagination).to be_present
+ expect(call.metadata.pagination.next).to be_a(JsonSchematize::EmptyValue)
+ end
+
+ it "sets remaining_pages to 0" do
+ expect(call.metadata.pagination.remaining_pages).to eq(0)
+ end
+ end
+
+ context "when cursor is within bounds with more pages" do
+ let(:cursor) { 10 }
+ let(:limit) { 5 }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+
+ it "sets pagination.next to present" do
+ expect(call.metadata.pagination).to be_present
+ expect(call.metadata.pagination.next).to be_present
+ expect(call.metadata.pagination.next).to be_a(CommandTower::Schema::Shared::Page)
+ end
+
+ it "sets remaining_pages correctly" do
+ expect(call.metadata.pagination.remaining_pages).to be > 0
+ end
+ end
+
+ context "when exactly one page of records" do
+ let(:total_count) { 10 }
+ let(:page) { 1 }
+ let(:limit) { 10 }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+
+ it "sets pagination.next to nil" do
+ expect(call.metadata.pagination).to be_present
+ expect(call.metadata.pagination.next).to be_a(JsonSchematize::EmptyValue)
+ end
+
+ it "sets remaining_pages to 0" do
+ expect(call.metadata.pagination.remaining_pages).to eq(0)
+ end
+ end
+ end
+ end
end
end
diff --git a/spec/services/command_tower/inbox_service/message/modify_spec.rb b/spec/services/command_tower/inbox_service/message/modify_spec.rb
index 99f1400..350d344 100644
--- a/spec/services/command_tower/inbox_service/message/modify_spec.rb
+++ b/spec/services/command_tower/inbox_service/message/modify_spec.rb
@@ -15,7 +15,7 @@
end
it "returns modified" do
- expect(call.modified).to be_a(CommandTower::Schema::Inbox::Modified)
+ expect(call.modified).to be_a(CommandTower::Schema::Shared::Inbox::Modified)
expect(call.modified.type).to eq(type)
expect(call.modified.ids).to include(*ids)
expect(call.modified.count).to eq(count)
@@ -38,7 +38,7 @@
end
it "returns modified" do
- expect(call.modified).to be_a(CommandTower::Schema::Inbox::Modified)
+ expect(call.modified).to be_a(CommandTower::Schema::Shared::Inbox::Modified)
expect(call.modified.type).to eq(type)
expect(call.modified.ids).to include(*ids.reject { _1 == additional_id })
expect(call.modified.count).to eq(count)
diff --git a/spec/services/command_tower/inbox_service/message/retrieve_spec.rb b/spec/services/command_tower/inbox_service/message/retrieve_spec.rb
index e2e66a8..284ae2e 100644
--- a/spec/services/command_tower/inbox_service/message/retrieve_spec.rb
+++ b/spec/services/command_tower/inbox_service/message/retrieve_spec.rb
@@ -13,12 +13,17 @@
end
it "sets message" do
- expect(call.message).to be_a(CommandTower::Schema::Inbox::MessageEntity)
+ expect(call.message).to be_a(CommandTower::Schema::Entities::Inbox::MessageEntity)
expect(call.message.title).to eq(message.title)
expect(call.message.id).to eq(message.id)
expect(call.message.text).to eq(message.text)
expect(call.message.viewed).to eq(true)
+ expect(call.message.created_at).to be_present
+ expect(call.message.created_at).to be_a(String)
+ expect(call.message.created_at).to eq(message.created_at.iso8601)
+ # Verify it's a valid ISO 8601 date string
+ expect { Time.iso8601(call.message.created_at) }.not_to raise_error
end
it "changes viewed" do
diff --git a/spec/services/command_tower/jwt/authenticate_user_spec.rb b/spec/services/command_tower/jwt/authenticate_user_spec.rb
index 459a818..4c51387 100644
--- a/spec/services/command_tower/jwt/authenticate_user_spec.rb
+++ b/spec/services/command_tower/jwt/authenticate_user_spec.rb
@@ -157,6 +157,14 @@
expect(call.failure?).to be(true)
end
+ it "sets status to 412" do
+ expect(call.status).to eq(412)
+ end
+
+ it "sets failure message" do
+ expect(call.msg).to eq("Email must be verified to continue")
+ end
+
it "sets user" do
expect(call.user.id).to be(user.id)
end
diff --git a/spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb b/spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb
new file mode 100644
index 0000000..0d3d876
--- /dev/null
+++ b/spec/services/command_tower/login_strategy/plain_text/change_password_spec.rb
@@ -0,0 +1,226 @@
+# frozen_string_literal: true
+
+RSpec.describe CommandTower::LoginStrategy::PlainText::ChangePassword do
+ # Sentinel values must never appear in msgs, hashes, returned payloads, or exception text
+ SENTINEL_CURRENT = "SentinelCurrentPassword_Aa1!"
+ SENTINEL_NEW = "SentinelNewPassword_Bb2!"
+ SENTINEL_WRONG = "SentinelWrongPassword_Cc3!"
+
+ let(:user) { create(:user, password: SENTINEL_CURRENT, password_confirmation: SENTINEL_CURRENT) }
+ let(:current_password) { SENTINEL_CURRENT }
+ let(:password) { SENTINEL_NEW }
+ let(:password_confirmation) { password }
+
+ describe ".call" do
+ subject(:call) do
+ described_class.(
+ user: user,
+ current_password: current_password,
+ password: password,
+ password_confirmation: password_confirmation
+ )
+ end
+
+ def assert_no_secret_leak!(result)
+ serialized = [
+ result.msg,
+ result.message,
+ result.try(:invalid_argument_hash)&.inspect,
+ result.try(:invalid_argument_keys)&.inspect,
+ ].compact.join(" ")
+
+ expect(serialized).not_to include(SENTINEL_CURRENT)
+ expect(serialized).not_to include(SENTINEL_NEW)
+ expect(serialized).not_to include(SENTINEL_WRONG)
+ expect(serialized).not_to include(user.reload.verifier_token) if user.verifier_token.present?
+ end
+
+ context "with valid current password and matching new passwords" do
+ let!(:old_verifier) { user.retreive_verifier_token! }
+ let!(:old_jwt) { CommandTower::Jwt::LoginCreate.(user: user.reload).token }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ assert_no_secret_leak!(call)
+ end
+
+ it "sets message" do
+ expect(call.message).to eq("Password has been successfully changed")
+ assert_no_secret_leak!(call)
+ end
+
+ it "does not return a JWT or verifier" do
+ call
+ expect(call.try(:token)).to be_nil
+ expect(call.respond_to?(:verifier_token) ? call.verifier_token : nil).to be_nil
+ assert_no_secret_leak!(call)
+ end
+
+ it "updates user password" do
+ call
+ expect(user.reload.authenticate(SENTINEL_NEW)).to be_truthy
+ end
+
+ it "invalidates old password" do
+ call
+ expect(user.reload.authenticate(SENTINEL_CURRENT)).to be_falsey
+ end
+
+ it "rotates verifier_token" do
+ call
+ expect(user.reload.verifier_token).not_to eq(old_verifier)
+ end
+
+ it "invalidates existing JWT sessions" do
+ call
+ auth = CommandTower::Jwt::AuthenticateUser.(token: old_jwt)
+ expect(auth.failure?).to eq(true)
+ end
+
+ it "allows login with the new password" do
+ call
+ login = CommandTower::LoginStrategy::PlainText::Login.(
+ identifier: user.email,
+ password: SENTINEL_NEW
+ )
+ expect(login.success?).to eq(true)
+ end
+
+ it "rejects login with the old password" do
+ call
+ login = CommandTower::LoginStrategy::PlainText::Login.(
+ identifier: user.email,
+ password: SENTINEL_CURRENT
+ )
+ expect(login.failure?).to eq(true)
+ end
+ end
+
+ context "with incorrect current password" do
+ let(:current_password) { SENTINEL_WRONG }
+ let!(:old_digest) { user.password_digest }
+ let!(:old_verifier) { user.retreive_verifier_token! }
+
+ it "fails with invalid_arguments" do
+ expect(call.failure?).to eq(true)
+ expect(call.invalid_arguments).to eq(true)
+ expect(call.invalid_argument_hash).to include(
+ current_password: hash_including(msg: "Incorrect current password")
+ )
+ assert_no_secret_leak!(call)
+ end
+
+ it "does not mutate password or verifier" do
+ call
+ user.reload
+ expect(user.password_digest).to eq(old_digest)
+ expect(user.verifier_token).to eq(old_verifier)
+ end
+ end
+
+ context "with password confirmation mismatch" do
+ let(:password_confirmation) { SENTINEL_WRONG }
+ let!(:old_digest) { user.password_digest }
+ let!(:old_verifier) { user.retreive_verifier_token! }
+
+ it "fails with invalid_arguments" do
+ expect(call.failure?).to eq(true)
+ expect(call.invalid_arguments).to eq(true)
+ expect(call.invalid_argument_hash).to include(
+ password_confirmation: hash_including(msg: "Password and confirmation do not match")
+ )
+ assert_no_secret_leak!(call)
+ end
+
+ it "does not mutate password or verifier" do
+ call
+ user.reload
+ expect(user.password_digest).to eq(old_digest)
+ expect(user.verifier_token).to eq(old_verifier)
+ end
+ end
+
+ context "with password too short" do
+ let(:password) { "short" }
+ let(:password_confirmation) { "short" }
+ let!(:old_digest) { user.password_digest }
+ let!(:old_verifier) { user.retreive_verifier_token! }
+
+ it "fails with invalid_arguments" do
+ expect(call.failure?).to eq(true)
+ expect(call.invalid_arguments).to eq(true)
+ assert_no_secret_leak!(call)
+ end
+
+ it "does not mutate password or verifier" do
+ call
+ user.reload
+ expect(user.password_digest).to eq(old_digest)
+ expect(user.verifier_token).to eq(old_verifier)
+ end
+ end
+
+ context "when password save succeeds but verifier rotation fails" do
+ let!(:old_digest) { user.password_digest }
+ let!(:old_verifier) { user.retreive_verifier_token! }
+
+ before do
+ allow_any_instance_of(User).to receive(:reset_verifier_token!).and_raise(StandardError, "simulated verifier failure")
+ end
+
+ it "fails with typed infrastructure failure" do
+ expect(call.failure?).to eq(true)
+ expect(call.msg).to eq("Failed to rotate session verifier")
+ expect(call.status).to eq(500)
+ expect(call.invalid_arguments).not_to eq(true)
+ assert_no_secret_leak!(call)
+ end
+
+ it "rolls back password and verifier" do
+ call
+ user.reload
+ expect(user.password_digest).to eq(old_digest)
+ expect(user.verifier_token).to eq(old_verifier)
+ expect(user.authenticate(SENTINEL_CURRENT)).to be_truthy
+ expect(user.authenticate(SENTINEL_NEW)).to be_falsey
+ end
+ end
+
+ context "when password save fails" do
+ let!(:old_digest) { user.password_digest }
+ let!(:old_verifier) { user.retreive_verifier_token! }
+
+ before do
+ allow(user).to receive(:save).and_return(false)
+ errors = ActiveModel::Errors.new(user)
+ errors.add(:password, "is invalid")
+ allow(user).to receive(:errors).and_return(errors)
+ end
+
+ it "fails with invalid_arguments" do
+ expect(call.failure?).to eq(true)
+ expect(call.invalid_arguments).to eq(true)
+ assert_no_secret_leak!(call)
+ end
+
+ it "does not rotate verifier" do
+ call
+ expect(user.reload.verifier_token).to eq(old_verifier)
+ end
+ end
+
+ context "with missing required arguments" do
+ it "fails when current_password is missing" do
+ result = described_class.(
+ user: user,
+ current_password: nil,
+ password: password,
+ password_confirmation: password_confirmation
+ )
+ expect(result.failure?).to eq(true)
+ expect(result.invalid_arguments).to eq(true)
+ assert_no_secret_leak!(result)
+ end
+ end
+ end
+end
diff --git a/spec/services/command_tower/login_strategy/plain_text/login_spec.rb b/spec/services/command_tower/login_strategy/plain_text/login_spec.rb
index 07b75bb..6c93ef8 100644
--- a/spec/services/command_tower/login_strategy/plain_text/login_spec.rb
+++ b/spec/services/command_tower/login_strategy/plain_text/login_spec.rb
@@ -10,9 +10,9 @@
describe ".call" do
subject(:call) { described_class.(**payload) }
- shared_examples "with incorrect credentials" do |argument|
- context "with incorrect #{argument}" do
- let(:payload) { super().merge(argument => "This is an Incorrect Value") }
+ shared_examples "with incorrect credentials" do
+ context "with incorrect identifier" do
+ let(:payload) { super().merge(identifier: "This is an Incorrect Value") }
let(:message) { "Unauthorized Access. Incorrect Credentials" }
it "fails" do
expect(call.failure?).to be(true)
@@ -32,7 +32,7 @@
it "sets invalid_argument_hash" do
expect(call.invalid_argument_hash).to include(
- argument => { msg: /#{message}/ }
+ identifier: { msg: /#{message}/ }
)
end
end
@@ -50,12 +50,12 @@
it "sets invalid_argument_hash" do
expect(call.invalid_argument_hash).to include(
- argument => { msg: /Unauthorized Access. Incorrect Credentials/ }
+ identifier: { msg: /Unauthorized Access. Incorrect Credentials/ }
)
end
it "sets invalid_argument_keys" do
- expect(call.invalid_argument_keys).to include(argument)
+ expect(call.invalid_argument_keys).to include(:identifier)
end
it "increases failed count" do
@@ -84,18 +84,20 @@
end
end
- context "with username" do
- let(:payload) { { username:, password: } }
+ context "with identifier" do
+ context "when identifier is username" do
+ let(:payload) { { identifier: username, password: } }
- include_examples "with valid credentials"
- include_examples "with incorrect credentials", :username
- end
+ include_examples "with valid credentials"
+ include_examples "with incorrect credentials"
+ end
- context "with email" do
- let(:payload) { { email:, password: } }
+ context "when identifier is email" do
+ let(:payload) { { identifier: email, password: } }
- include_examples "with valid credentials"
- include_examples "with incorrect credentials", :email
+ include_examples "with valid credentials"
+ include_examples "with incorrect credentials"
+ end
end
end
end
diff --git a/spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb b/spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb
new file mode 100644
index 0000000..bbec775
--- /dev/null
+++ b/spec/services/command_tower/login_strategy/plain_text/password_reset/reset_spec.rb
@@ -0,0 +1,301 @@
+# frozen_string_literal: true
+
+RSpec.describe CommandTower::LoginStrategy::PlainText::PasswordReset::Reset do
+ let(:user) { create(:user, password: "old_password123") }
+ let(:token) do
+ result = CommandTower::Secrets::Generate.(
+ user: user,
+ secret_length: 32,
+ reason: CommandTower::Secrets::PASSWORD_RESET,
+ use_count_max: 1,
+ death_time: 1.hour,
+ type: CommandTower::Secrets::ALPHANUMERIC,
+ cleanse: false
+ )
+ result.secret
+ end
+ let(:password) { "new_password123" }
+ let(:password_confirmation) { password }
+ let(:email) { nil }
+
+ describe ".call" do
+ subject(:call) { described_class.(token: token, email: email, password: password, password_confirmation: password_confirmation) }
+
+ context "with valid token and matching passwords" do
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+
+ it "sets message" do
+ expect(call.message).to eq("Password has been successfully reset")
+ end
+
+ it "updates user password" do
+ call
+ expect(user.reload.authenticate(password)).to be_truthy
+ end
+
+ it "invalidates old password" do
+ call
+ expect(user.reload.authenticate("old_password123")).to be_falsey
+ end
+
+ it "increments token use_count" do
+ user_secret = UserSecret.find_by(secret: token)
+ expect { call }.to change { user_secret.reload.use_count }.by(1)
+ end
+ end
+
+ context "with password mismatch" do
+ let(:password_confirmation) { "different_password" }
+
+ it "fails" do
+ expect(call.failure?).to eq(true)
+ end
+
+ it "sets invalid_arguments" do
+ expect(call.invalid_arguments).to eq(true)
+ end
+
+ it "sets invalid_argument_hash" do
+ expect(call.invalid_argument_hash).to include(password_confirmation: hash_including(msg: "Password and confirmation do not match"))
+ end
+ end
+
+ context "with password too short" do
+ let(:password) { "short" }
+ let(:password_confirmation) { "short" }
+
+ it "fails" do
+ expect(call.failure?).to eq(true)
+ end
+
+ it "sets invalid_arguments" do
+ expect(call.invalid_arguments).to eq(true)
+ end
+ end
+
+ context "with invalid token" do
+ let(:token) { "invalid_token_12345" }
+
+ it "fails" do
+ expect(call.failure?).to eq(true)
+ end
+
+ it "sets status to 401" do
+ expect(call.status).to eq(401)
+ end
+
+ it "sets message" do
+ expect(call.msg).to eq("Invalid token")
+ end
+ end
+
+ context "with expired token" do
+ let(:token) do
+ result = CommandTower::Secrets::Generate.(
+ user: user,
+ secret_length: 32,
+ reason: CommandTower::Secrets::PASSWORD_RESET,
+ use_count_max: 1,
+ death_time: 1.hour,
+ type: CommandTower::Secrets::ALPHANUMERIC,
+ cleanse: false
+ )
+ secret = result.secret
+ # Manually expire the token by setting death_time in the past
+ user_secret = UserSecret.find_by(secret: secret)
+ user_secret.update!(death_time: 1.hour.ago)
+ secret
+ end
+
+ it "fails" do
+ expect(call.failure?).to eq(true)
+ end
+
+ it "sets status to 401" do
+ expect(call.status).to eq(401)
+ end
+
+ it "sets message" do
+ expect(call.msg).to eq("Invalid token")
+ end
+ end
+
+ context "with used token (already used for reset)" do
+ before do
+ # Use the token for reset once (increments use_count to 1)
+ # With use_count_max: 1, use_count: 1 is still valid (1 <= 1)
+ # So we need to use it twice to make it invalid
+ CommandTower::Secrets::Verify.(
+ secret: token,
+ reason: CommandTower::Secrets::PASSWORD_RESET,
+ access_count: true
+ )
+ # Use it again to exceed the max
+ CommandTower::Secrets::Verify.(
+ secret: token,
+ reason: CommandTower::Secrets::PASSWORD_RESET,
+ access_count: true
+ )
+ end
+
+ it "fails" do
+ expect(call.failure?).to eq(true)
+ end
+
+ it "sets status to 401" do
+ expect(call.status).to eq(401)
+ end
+
+ it "sets message" do
+ expect(call.msg).to eq("Invalid token")
+ end
+ end
+
+ context "with missing token" do
+ let(:token) { nil }
+
+ it "fails" do
+ expect(call.failure?).to eq(true)
+ end
+
+ it "sets invalid_arguments" do
+ expect(call.invalid_arguments).to eq(true)
+ end
+ end
+
+ context "with missing password" do
+ let(:password) { nil }
+
+ it "fails" do
+ expect(call.failure?).to eq(true)
+ end
+
+ it "sets invalid_arguments" do
+ expect(call.invalid_arguments).to eq(true)
+ end
+ end
+
+ context "with require_email: false (backward compatibility)" do
+ before do
+ allow(CommandTower.config.login.plain_text.password_reset).to receive(:require_email).and_return(false)
+ end
+
+ context "when email is not provided" do
+ let(:email) { nil }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+
+ it "updates user password" do
+ call
+ expect(user.reload.authenticate(password)).to be_truthy
+ end
+ end
+
+ context "when email is provided and matches" do
+ let(:email) { user.email }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+
+ it "updates user password" do
+ call
+ expect(user.reload.authenticate(password)).to be_truthy
+ end
+ end
+
+ context "when email is provided but does not match" do
+ let(:email) { "wrong@example.com" }
+
+ it "fails" do
+ expect(call.failure?).to eq(true)
+ end
+
+ it "sets status to 401" do
+ expect(call.status).to eq(401)
+ end
+
+ it "sets message" do
+ expect(call.msg).to eq("Invalid token")
+ end
+ end
+ end
+
+ context "with require_email: true" do
+ before do
+ allow(CommandTower.config.login.plain_text.password_reset).to receive(:require_email).and_return(true)
+ end
+
+ context "when email is not provided" do
+ let(:email) { nil }
+
+ it "fails" do
+ expect(call.failure?).to eq(true)
+ end
+
+ it "sets status to 400" do
+ expect(call.status).to eq(400)
+ end
+
+ it "sets message" do
+ expect(call.msg).to eq("Email is required")
+ end
+ end
+
+ context "when email is provided and matches" do
+ let(:email) { user.email }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+
+ it "updates user password" do
+ call
+ expect(user.reload.authenticate(password)).to be_truthy
+ end
+ end
+
+ context "when email is provided but does not match" do
+ let(:email) { "wrong@example.com" }
+
+ it "fails" do
+ expect(call.failure?).to eq(true)
+ end
+
+ it "sets status to 401" do
+ expect(call.status).to eq(401)
+ end
+
+ it "sets message" do
+ expect(call.msg).to eq("Invalid token")
+ end
+ end
+ end
+
+ context "with email normalization" do
+ before do
+ allow(CommandTower.config.login.plain_text.password_reset).to receive(:require_email).and_return(false)
+ end
+
+ context "when email has different case" do
+ let(:email) { user.email.upcase }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+ end
+
+ context "when email has whitespace" do
+ let(:email) { " #{user.email} " }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb b/spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb
new file mode 100644
index 0000000..2203edd
--- /dev/null
+++ b/spec/services/command_tower/login_strategy/plain_text/password_reset/send_spec.rb
@@ -0,0 +1,172 @@
+# frozen_string_literal: true
+
+RSpec.describe CommandTower::LoginStrategy::PlainText::PasswordReset::Send do
+ let(:email) { Faker::Internet.email }
+ let(:user) { create(:user, email: email) }
+
+ describe ".call" do
+ subject(:call) { described_class.(email:) }
+
+ context "with existing user" do
+ before { user }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+
+ it "sets message" do
+ expect(call.message).to eq("If an account exists with that email, a password reset link has been sent.")
+ end
+
+ it "sends mail" do
+ expect { call }.to change { ActionMailer::Base.deliveries.count }.by(1)
+ end
+
+ it "creates user secret" do
+ expect { call }.to change { UserSecret.where(reason: CommandTower::Secrets::PASSWORD_RESET).count }.by(1)
+ end
+
+ it "cleanses old tokens" do
+ # Create an old token
+ old_token = CommandTower::Secrets::Generate.(
+ user: user,
+ secret_length: 32,
+ reason: CommandTower::Secrets::PASSWORD_RESET,
+ use_count_max: 1,
+ death_time: 1.hour,
+ type: CommandTower::Secrets::ALPHANUMERIC,
+ cleanse: false
+ ).secret
+
+ # Request new token (should cleanse old one)
+ call
+
+ # Old token should be gone
+ expect(UserSecret.find_by(secret: old_token)).to be_nil
+ end
+
+ context "with email failure" do
+ before do
+ allow_any_instance_of(CommandTower::PasswordResetMailer).to receive(:reset_password).and_raise(StandardError, "SMTP Error")
+ end
+
+ it "still succeeds" do
+ expect(call.success?).to eq(true)
+ end
+
+ it "sets message" do
+ expect(call.message).to eq("If an account exists with that email, a password reset link has been sent.")
+ end
+
+ it "does not send mail" do
+ expect { call }.not_to change { ActionMailer::Base.deliveries.count }
+ end
+ end
+
+ context "with token generation failure" do
+ before do
+ allow(CommandTower::Secrets::Generate).to receive(:call).and_return(
+ double(success?: false, failure?: true, msg: "Generation failed")
+ )
+ end
+
+ it "still succeeds" do
+ expect(call.success?).to eq(true)
+ end
+
+ it "sets message" do
+ expect(call.message).to eq("If an account exists with that email, a password reset link has been sent.")
+ end
+ end
+
+ context "with reset_password_path config" do
+ before do
+ allow(CommandTower.config.login.plain_text.password_reset).to receive(:reset_password_path).and_return("/custom-reset-path")
+ allow(CommandTower.config.app).to receive(:composed_url).and_return("https://example.com")
+ end
+
+ it "uses reset_password_path in email template" do
+ call
+ mail = ActionMailer::Base.deliveries.last
+ expect(mail.body.to_s).to include("/custom-reset-path")
+ end
+ end
+
+ context "with require_email: false" do
+ before do
+ allow(CommandTower.config.login.plain_text.password_reset).to receive(:require_email).and_return(false)
+ end
+
+ it "email link does not include email parameter" do
+ call
+ mail = ActionMailer::Base.deliveries.last
+ token = UserSecret.where(reason: CommandTower::Secrets::PASSWORD_RESET).last.secret
+ # Check that URL contains token but not email parameter
+ expect(mail.body.to_s).to include("token=#{token}")
+ expect(mail.body.to_s).not_to include("&email=")
+ end
+ end
+
+ context "with require_email: true" do
+ before do
+ allow(CommandTower.config.login.plain_text.password_reset).to receive(:require_email).and_return(true)
+ end
+
+ it "email link includes email parameter" do
+ call
+ mail = ActionMailer::Base.deliveries.last
+ token = UserSecret.where(reason: CommandTower::Secrets::PASSWORD_RESET).last.secret
+ body = mail.body.to_s
+ # Check that URL contains both token and email parameter (HTML encoded as &email=)
+ expect(body).to include("token=#{token}")
+ expect(body).to match(/&email=|&email=/)
+ expect(body).to include(CGI.escape(email))
+ end
+ end
+ end
+
+ context "with non-existent user" do
+ let(:email) { "nonexistent@example.com" }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+
+ it "sets message" do
+ expect(call.message).to eq("If an account exists with that email, a password reset link has been sent.")
+ end
+
+ it "does not send mail" do
+ expect { call }.not_to change { ActionMailer::Base.deliveries.count }
+ end
+
+ it "does not create user secret" do
+ expect { call }.not_to change { UserSecret.count }
+ end
+ end
+
+ context "with invalid email" do
+ let(:email) { "not-an-email" }
+
+ it "fails" do
+ expect(call.failure?).to eq(true)
+ end
+
+ it "sets invalid_arguments" do
+ expect(call.invalid_arguments).to eq(true)
+ end
+ end
+
+ context "with missing email" do
+ let(:email) { nil }
+
+ it "fails" do
+ expect(call.failure?).to eq(true)
+ end
+
+ it "sets invalid_arguments" do
+ expect(call.invalid_arguments).to eq(true)
+ end
+ end
+ end
+end
diff --git a/spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb b/spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb
new file mode 100644
index 0000000..605305f
--- /dev/null
+++ b/spec/services/command_tower/login_strategy/plain_text/password_reset/validate_spec.rb
@@ -0,0 +1,245 @@
+# frozen_string_literal: true
+
+RSpec.describe CommandTower::LoginStrategy::PlainText::PasswordReset::Validate do
+ let(:user) { create(:user) }
+ let(:token) do
+ result = CommandTower::Secrets::Generate.(
+ user: user,
+ secret_length: 32,
+ reason: CommandTower::Secrets::PASSWORD_RESET,
+ use_count_max: 1,
+ death_time: 1.hour,
+ type: CommandTower::Secrets::ALPHANUMERIC,
+ cleanse: false
+ )
+ result.secret
+ end
+
+ describe ".call" do
+ subject(:call) { described_class.(token: token, email: email) }
+
+ let(:email) { nil }
+
+ context "with valid token" do
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+
+ it "sets valid to true" do
+ expect(call.valid).to eq(true)
+ end
+
+ it "sets expires_at" do
+ expect(call.expires_at).to be_present
+ end
+ end
+
+ context "with require_email: false (backward compatibility)" do
+ before do
+ allow(CommandTower.config.login.plain_text.password_reset).to receive(:require_email).and_return(false)
+ end
+
+ context "when email is not provided" do
+ let(:email) { nil }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+
+ it "sets valid to true" do
+ expect(call.valid).to eq(true)
+ end
+ end
+
+ context "when email is provided and matches" do
+ let(:email) { user.email }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+
+ it "sets valid to true" do
+ expect(call.valid).to eq(true)
+ end
+ end
+
+ context "when email is provided but does not match" do
+ let(:email) { "wrong@example.com" }
+
+ it "fails" do
+ expect(call.failure?).to eq(true)
+ end
+
+ it "sets status to 401" do
+ expect(call.status).to eq(401)
+ end
+
+ it "sets message" do
+ expect(call.msg).to eq("Invalid token")
+ end
+ end
+ end
+
+ context "with require_email: true" do
+ before do
+ allow(CommandTower.config.login.plain_text.password_reset).to receive(:require_email).and_return(true)
+ end
+
+ context "when email is not provided" do
+ let(:email) { nil }
+
+ it "fails" do
+ expect(call.failure?).to eq(true)
+ end
+
+ it "sets status to 400" do
+ expect(call.status).to eq(400)
+ end
+
+ it "sets message" do
+ expect(call.msg).to eq("Email is required")
+ end
+ end
+
+ context "when email is provided and matches" do
+ let(:email) { user.email }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+
+ it "sets valid to true" do
+ expect(call.valid).to eq(true)
+ end
+ end
+
+ context "when email is provided but does not match" do
+ let(:email) { "wrong@example.com" }
+
+ it "fails" do
+ expect(call.failure?).to eq(true)
+ end
+
+ it "sets status to 401" do
+ expect(call.status).to eq(401)
+ end
+
+ it "sets message" do
+ expect(call.msg).to eq("Invalid token")
+ end
+ end
+ end
+
+ context "with email normalization" do
+ before do
+ allow(CommandTower.config.login.plain_text.password_reset).to receive(:require_email).and_return(false)
+ end
+
+ context "when email has different case" do
+ let(:email) { user.email.upcase }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+ end
+
+ context "when email has whitespace" do
+ let(:email) { " #{user.email} " }
+
+ it "succeeds" do
+ expect(call.success?).to eq(true)
+ end
+ end
+ end
+
+ context "with invalid token" do
+ let(:token) { "invalid_token_12345" }
+
+ it "fails" do
+ expect(call.failure?).to eq(true)
+ end
+
+ it "sets status to 401" do
+ expect(call.status).to eq(401)
+ end
+
+ it "sets message" do
+ expect(call.msg).to eq("Invalid token")
+ end
+ end
+
+ context "with expired token" do
+ let(:token) do
+ result = CommandTower::Secrets::Generate.(
+ user: user,
+ secret_length: 32,
+ reason: CommandTower::Secrets::PASSWORD_RESET,
+ use_count_max: 1,
+ death_time: 1.hour,
+ type: CommandTower::Secrets::ALPHANUMERIC,
+ cleanse: false
+ )
+ secret = result.secret
+ # Manually expire the token by setting death_time in the past
+ user_secret = UserSecret.find_by(secret: secret)
+ user_secret.update!(death_time: 1.hour.ago)
+ secret
+ end
+
+ it "fails" do
+ expect(call.failure?).to eq(true)
+ end
+
+ it "sets status to 401" do
+ expect(call.status).to eq(401)
+ end
+
+ it "sets message" do
+ expect(call.msg).to eq("Invalid token")
+ end
+ end
+
+ context "with used token (used for reset)" do
+ before do
+ # Use the token for reset (increments use_count to 1, which exceeds use_count_max of 1)
+ # Actually, with use_count_max: 1, use_count: 1 is still valid (1 <= 1)
+ # So we need to use it twice to make it invalid
+ CommandTower::Secrets::Verify.(
+ secret: token,
+ reason: CommandTower::Secrets::PASSWORD_RESET,
+ access_count: true
+ )
+ # Use it again to exceed the max
+ CommandTower::Secrets::Verify.(
+ secret: token,
+ reason: CommandTower::Secrets::PASSWORD_RESET,
+ access_count: true
+ )
+ end
+
+ it "fails" do
+ expect(call.failure?).to eq(true)
+ end
+
+ it "sets status to 401" do
+ expect(call.status).to eq(401)
+ end
+
+ it "sets message" do
+ expect(call.msg).to eq("Invalid token")
+ end
+ end
+
+ context "with missing token" do
+ let(:token) { nil }
+
+ it "fails" do
+ expect(call.failure?).to eq(true)
+ end
+
+ it "sets invalid_arguments" do
+ expect(call.invalid_arguments).to eq(true)
+ end
+ end
+ end
+end