diff --git a/Dockerfile b/Dockerfile index e2308f371..25ee37e68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,8 @@ FROM base AS build # Install packages needed to build gems RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y build-essential git libvips pkg-config libyaml-dev + apt-get install -y --no-install-recommends -y build-essential git libvips pkg-config libyaml-dev default-libmysqlclient-dev && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives # Install application gems COPY Gemfile Gemfile.lock .ruby-version ./ @@ -47,7 +48,7 @@ FROM base # Install packages needed for deployment RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y curl libsqlite3-0 libvips build-essential ffmpeg groff libreoffice-writer libreoffice-impress libreoffice-calc mupdf-tools && \ + apt-get install --no-install-recommends -y curl libsqlite3-0 libvips build-essential ffmpeg groff libreoffice-writer libreoffice-impress libreoffice-calc mupdf-tools default-libmysqlclient-dev && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Copy built artifacts: gems, application diff --git a/Gemfile b/Gemfile index 8c43ff533..e5e3637e9 100644 --- a/Gemfile +++ b/Gemfile @@ -32,6 +32,16 @@ gem "image_processing", "~> 1.14" gem "platform_agent" gem "aws-sdk-s3", require: false +# 37id and Queenbee integration +gem "signal_id", bc: "signal_id", branch: "rails4" +gem "mysql2" # needed by signal_id +gem "queuety", bc: "queuety", branch: "rails4" # needed by signal_id +gem "service_concurrency_prevention", bc: "service_concurrency_prevention" # needed by queuety +gem "portfolio", ">= 4.6", bc: "portfolio" # needed by signal_id +gem "file_repository", "~> 1.4.5", bc: "file_repository" # needed by portfolio +gem "queenbee", bc: "queenbee-plugin" +gem "activeresource", require: "active_resource" # needed by queenbee + # Telemetry, logging, and operations gem "mission_control-jobs" gem "sentry-ruby" @@ -55,4 +65,5 @@ group :test do gem "selenium-webdriver" gem "webmock" gem "vcr" + gem "mocha" end diff --git a/Gemfile.lock b/Gemfile.lock index ab120f738..a1e1ced06 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -15,6 +15,12 @@ GIT railties (>= 8.1.alpha) zeitwerk +GIT + remote: https://github.com/basecamp/file_repository + revision: 17e90fc35ccb9f0a0dae1ed8623f01386696002d + specs: + file_repository (1.4.5) + GIT remote: https://github.com/basecamp/kamal revision: 344e2d7995e0134aa52f195a984d8d5e24e31d94 @@ -32,6 +38,32 @@ GIT thor (~> 1.3) zeitwerk (>= 2.6.18, < 3.0) +GIT + remote: https://github.com/basecamp/portfolio + revision: 26dd0ccbe4aad5a5c539f90c00e82b6175443636 + specs: + portfolio (5.2.0) + activesupport + file_repository (>= 1.4.4) + +GIT + remote: https://github.com/basecamp/queenbee-plugin + revision: bf887b5c128570d1b19a0140146ae9aa4ae2915f + specs: + queenbee (3.2.0) + activeresource + builder + rexml + +GIT + remote: https://github.com/basecamp/queuety + revision: bf7e2a552fb674533d3e092f06dbf9b5d1ebc753 + branch: rails4 + specs: + queuety (4.0.2) + activerecord + service_concurrency_prevention + GIT remote: https://github.com/basecamp/rails-structured-logging revision: 76960cb5c15fc2b6b5f7542e05d7dcc031cef9e6 @@ -40,6 +72,29 @@ GIT json rails (>= 6.0.0) +GIT + remote: https://github.com/basecamp/service_concurrency_prevention + revision: 320da99431718615ed86f8723e8b9d3259c602a3 + specs: + service_concurrency_prevention (1.1.1) + activesupport + +GIT + remote: https://github.com/basecamp/signal_id + revision: 129483ccc1d9452fa7713080fcbf0c012b2f4107 + branch: rails4 + specs: + signal_id (4.3.2) + bcrypt-ruby (>= 3.0.0) + google-id-token (>= 1.4.0) + mocha + multi_json + portfolio (>= 4.0) + pwned (~> 2.0) + queuety (>= 3.0) + rails (>= 4.0.2) + rails-controller-testing + GIT remote: https://github.com/crmne/ruby_llm.git revision: a35aa111a0bb36b7f0363b1f1f2a25e3d5378fd3 @@ -159,6 +214,14 @@ GEM specs: action_text-trix (2.1.15) railties + activemodel-serializers-xml (1.0.3) + activemodel (>= 5.0.0.a) + activesupport (>= 5.0.0.a) + builder (~> 3.1) + activeresource (6.1.4) + activemodel (>= 6.0) + activemodel-serializers-xml (~> 1.0) + activesupport (>= 6.0) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) @@ -182,6 +245,8 @@ GEM aws-eventstream (~> 1, >= 1.0.2) base64 (0.3.0) bcrypt (3.1.20) + bcrypt-ruby (3.1.5) + bcrypt (>= 3.1.3) bcrypt_pbkdf (1.1.1) benchmark (0.4.1) bigdecimal (3.2.2) @@ -243,6 +308,8 @@ GEM addressable (>= 2.5.0) globalid (1.2.1) activesupport (>= 6.1) + google-id-token (1.4.2) + jwt (>= 1) hashdiff (1.2.0) i18n (1.14.7) concurrent-ruby (~> 1.0) @@ -263,6 +330,8 @@ GEM activesupport (>= 5.0.0) jmespath (1.6.2) json (2.12.2) + jwt (2.10.1) + base64 language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) @@ -292,8 +361,12 @@ GEM railties (>= 7.1) stimulus-rails turbo-rails + mocha (2.7.1) + ruby2_keywords (>= 0.0.5) msgpack (1.8.0) + multi_json (1.15.0) multipart-post (2.4.1) + mysql2 (0.5.6) net-http (0.6.0) uri net-imap (0.5.8) @@ -343,6 +416,7 @@ GEM public_suffix (6.0.2) puma (6.6.0) nio4r (~> 2.0) + pwned (2.4.1) raabro (1.4.0) racc (1.8.1) rack (3.1.16) @@ -355,6 +429,10 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) + 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.3.0) activesupport (>= 5.0.0) minitest @@ -410,6 +488,7 @@ GEM ruby-vips (2.2.3) ffi (~> 1.12) logger + ruby2_keywords (0.0.5) rubyzip (2.4.1) securerandom (0.4.1) selenium-webdriver (4.33.0) @@ -506,6 +585,7 @@ PLATFORMS DEPENDENCIES actiontext-lexical! active_record-tenanted! + activeresource aws-sdk-s3 bcrypt (~> 3.1.7) bootsnap @@ -513,15 +593,21 @@ DEPENDENCIES bundler-audit capybara debug + file_repository (~> 1.4.5)! geared_pagination (~> 1.2) image_processing (~> 1.14) importmap-rails jbuilder kamal! mission_control-jobs + mocha + mysql2 platform_agent + portfolio (>= 4.6)! propshaft puma (>= 5.0) + queenbee! + queuety! rails! rails_structured_logging! redcarpet @@ -532,6 +618,8 @@ DEPENDENCIES selenium-webdriver sentry-rails sentry-ruby + service_concurrency_prevention! + signal_id! solid_cable (>= 3.0) solid_cache (~> 1.0) solid_queue (~> 1.1) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index af52c2fd6..37f663be8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,5 @@ class ApplicationController < ActionController::Base - include Authentication, CurrentTimezone, SetPlatform, WriterAffinity + include Authentication, CurrentRequest, CurrentTimezone, SetPlatform, WriterAffinity stale_when_importmap_changes allow_browser versions: :modern diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 746d3cda5..dc75fb777 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -2,6 +2,7 @@ module Authentication extend ActiveSupport::Concern included do + before_action :set_current_account before_action :require_authentication helper_method :authenticated? end @@ -16,6 +17,12 @@ module Authentication skip_before_action :require_authentication, **options before_action :resume_session, **options end + + def require_untenanted_access(**options) + skip_before_action :set_current_account, **options + skip_before_action :require_authentication, **options + before_action :redirect_tenanted_request, **options + end end private @@ -40,6 +47,10 @@ module Authentication def request_authentication session[:return_to_after_authenticating] = request.url + + # # When we're ready to flip the switch to Launchpad authentication, uncomment this line: + # redirect_to Launchpad.login_url(account: Current.account), allow_other_host: true + redirect_to new_session_path end @@ -51,6 +62,9 @@ module Authentication redirect_to root_url if authenticated? end + def redirect_tenanted_request + redirect_to root_url if ApplicationRecord.current_tenant + end def start_new_session_for(user) user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| @@ -64,6 +78,10 @@ module Authentication cookies.signed.permanent[:session_token] = { value: session.signed_id, httponly: true, same_site: :lax } end + def set_current_account + Current.account = Account.first + end + def terminate_session Current.session.destroy cookies.delete(:session_token) diff --git a/app/controllers/concerns/current_request.rb b/app/controllers/concerns/current_request.rb new file mode 100644 index 000000000..b7e4af73d --- /dev/null +++ b/app/controllers/concerns/current_request.rb @@ -0,0 +1,13 @@ +module CurrentRequest + extend ActiveSupport::Concern + + included do + before_action do + Current.http_method = request.method + Current.request_id = request.uuid + Current.user_agent = request.user_agent + Current.ip_address = request.ip + Current.referrer = request.referrer + end + end +end diff --git a/app/controllers/sessions/launchpad_controller.rb b/app/controllers/sessions/launchpad_controller.rb new file mode 100644 index 000000000..0e2ad16a7 --- /dev/null +++ b/app/controllers/sessions/launchpad_controller.rb @@ -0,0 +1,27 @@ +class Sessions::LaunchpadController < ApplicationController + require_unauthenticated_access + + before_action :store_sig, only: :show + before_action :restore_and_clear_sig, only: :update + + def show + end + + def update + if user = Current.account.signal_account.authenticate(sig: @sig).try(:peer) + start_new_session_for user + redirect_to after_authentication_url + else + render plain: "Authentication failed. This is probably a bug.", status: :unauthorized + end + end + + private + def store_sig + cookies[:_fizzy_launchpad_sig] = params.expect(:sig) + end + + def restore_and_clear_sig + @sig = cookies.delete :_fizzy_launchpad_sig + end +end diff --git a/app/controllers/signup/accounts_controller.rb b/app/controllers/signup/accounts_controller.rb new file mode 100644 index 000000000..4c0cf9de2 --- /dev/null +++ b/app/controllers/signup/accounts_controller.rb @@ -0,0 +1,30 @@ +class Signup::AccountsController < Signup::BaseController + before_action :reset_signup_storage + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_account_path, alert: "Try again later." } + + def new + @signup = Signup.new + end + + def create + @signup = Signup.new(signup_params) + + if @signup.recognized? + store_signup + redirect_to Launchpad.authentication_url(purpose: "signup", login_hint: @signup.email_address, redirect_uri: signup_session_url), allow_other_host: true + elsif @signup.process + redirect_to_account(@signup.account) + else + render :new, status: :unprocessable_entity + end + end + + private + def signup_params + params.require(:signup).permit(*Signup::PERMITTED_KEYS) + end + + def store_signup + @signup.to_h.each { |key, value| signup_storage[key.to_s] = value } + end +end diff --git a/app/controllers/signup/base_controller.rb b/app/controllers/signup/base_controller.rb new file mode 100644 index 000000000..ad11d6e1d --- /dev/null +++ b/app/controllers/signup/base_controller.rb @@ -0,0 +1,35 @@ +class Signup::BaseController < ApplicationController + require_untenanted_access + + http_basic_authenticate_with( + name: Rails.application.credentials.dig(:account_signup_http_basic_auth, :name), + password: Rails.application.credentials.dig(:account_signup_http_basic_auth, :password) + ) + + attr_reader :authenticated_identity + + private + def signup_storage + session[:signup] ||= {} + end + + def reset_signup_storage + session.delete(:signup) + end + + def authenticated_identity=(identity) + @authenticated_identity = identity + signup_storage["identity_id"] = identity.try(:id) + end + + def set_authenticated_identity + if identity_id = signup_storage["identity_id"] + @authenticated_identity = SignalId::Identity.find_by(id: identity_id) + end + end + + def redirect_to_account(account) + redirect_to account.signal_account.owner.remote_login_url(proceed_to: root_path), + allow_other_host: true + end +end diff --git a/app/controllers/signup/completions_controller.rb b/app/controllers/signup/completions_controller.rb new file mode 100644 index 000000000..4f97055a0 --- /dev/null +++ b/app/controllers/signup/completions_controller.rb @@ -0,0 +1,23 @@ +class Signup::CompletionsController < Signup::BaseController + before_action :set_authenticated_identity, only: :create + + def new + end + + def create + @signup = Signup.new(signup_params) + @signup.signal_identity = authenticated_identity + + if @signup.process + reset_signup_storage + redirect_to_account(@signup.account) + else + render plain: "Could not complete signup. This is probably a bug.", status: :unprocessable_entity, layout: false + end + end + + private + def signup_params + signup_storage.slice(*Signup::PERMITTED_KEYS.map(&:to_s)) + end +end diff --git a/app/controllers/signup/sessions_controller.rb b/app/controllers/signup/sessions_controller.rb new file mode 100644 index 000000000..b33c0cf2f --- /dev/null +++ b/app/controllers/signup/sessions_controller.rb @@ -0,0 +1,9 @@ +class Signup::SessionsController < Signup::BaseController + def create + if self.authenticated_identity = SignalId::Identity.authenticate(params.permit(:sig)) + redirect_to new_signup_completion_url + else + render plain: "Authentication failed. This is probably a bug.", status: :unauthorized + end + end +end diff --git a/app/models/account.rb b/app/models/account.rb index afd022441..25b74efde 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,5 +1,14 @@ class Account < ApplicationRecord - include Entropic, Joinable + include Entropic, Joinable, SignalAccount has_many_attached :uploads + + def setup_basic_template + user = User.first + + Closure::Reason.create_defaults + Collection.create!(name: "Cards", creator: user, all_access: true) + workflow = Workflow.create!(name: "Basic") + Collection.first.update!(workflow: workflow) + end end diff --git a/app/models/account/signal_account.rb b/app/models/account/signal_account.rb new file mode 100644 index 000000000..861626909 --- /dev/null +++ b/app/models/account/signal_account.rb @@ -0,0 +1,27 @@ +module Account::SignalAccount + extend ActiveSupport::Concern + + included do + # TODO: remove the "optional: true" once we've populated the accounts properly + belongs_to :signal_account, class_name: "SignalId::Account", primary_key: :queenbee_id, foreign_key: :queenbee_id, optional: true + end + + class_methods do + def create_with_admin_user(queenbee_id:) + new(queenbee_id: queenbee_id).tap do |account| + SignalId::Database.on_master do + account.name = account.signal_account.name + account.save! + + User.create!( + name: account.signal_account.owner.name, + email_address: account.signal_account.owner.email_address, + signal_user_id: account.signal_account.owner.id, + role: "admin", + password: SecureRandom.hex(36) # TODO: remove password column? + ) + end + end + end + end +end diff --git a/app/models/current.rb b/app/models/current.rb index a4d0f0864..5a85b2e16 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -1,5 +1,7 @@ class Current < ActiveSupport::CurrentAttributes attribute :session + attribute :http_method, :request_id, :user_agent, :ip_address, :referrer + attribute :account delegate :user, to: :session, allow_nil: true end diff --git a/app/models/launchpad.rb b/app/models/launchpad.rb new file mode 100644 index 000000000..6001dac82 --- /dev/null +++ b/app/models/launchpad.rb @@ -0,0 +1,20 @@ +# lifted from bc3 app/models/launchpad.rb +module Launchpad + extend self + + delegate :url, to: "SignalId.launchpad" + + def login_url(product: false, account: nil, **params) + url product_account_path("/signin", product: product, account: account), params + end + + def authentication_url(**params) + url "/authenticate", params.merge(product: :fizzy) + end + + def product_account_path(path = nil, product: false, account: nil) + product_path = "/fizzy" if product || account + account_path = "/#{account.signal_account.id}" if account + [ product_path, account_path, path ].compact.join + end +end diff --git a/app/models/signup.rb b/app/models/signup.rb new file mode 100644 index 000000000..e11ab3bfd --- /dev/null +++ b/app/models/signup.rb @@ -0,0 +1,159 @@ +class Signup + include ActiveModel::Model + include ActiveModel::Attributes + include ActiveModel::Validations + + PERMITTED_KEYS = %i[ full_name email_address password company_name ] + + # Input attribute + attr_accessor :company_name + + # Input attributes when not using an existing identity + attr_accessor :full_name, :email_address, :password + + # Input attribute when using an existing identity, output when creating new identity + attr_accessor :signal_identity + + # Output attributes + attr_reader :queenbee_account, :signal_account, :account, :user + + validate :validate_new_identity + + def initialize(...) + @full_name = nil + @email_address = nil + @password = nil + @company_name = nil + @signal_identity = nil + @queenbee_account = nil + @account = nil + @user = nil + + super + end + + def process(destroy_existing_tenant: false) + return false unless valid? + + create_signal_identity + create_queenbee_account + create_tenant(destroy_existing_tenant:) + + true + rescue => error + destroy_tenant + destroy_queenbee_account + destroy_signal_identity + + errors.add(:base, "An error occurred during signup: #{error.message}") + + false + end + + def recognized? + SignalId::Identity.find_by_email_address(email_address).present? + end + + def tenant_name + @tenant_name ||= signal_account.subdomain + end + + def to_h + { + full_name: full_name, + email_address: email_address, + company_name: company_name + }.compact_blank + end + + private + def create_signal_identity + unless signal_identity.present? + SignalId::Database.on_master do + @signal_identity = build_new_identity.tap(&:save!) + end + end + end + + def create_queenbee_account + @queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes) + SignalId::Database.on_master do + @signal_account = SignalId::Account.find_by(queenbee_id: @queenbee_account.id) + end + end + + def create_tenant(destroy_existing_tenant: false, &block) + ApplicationRecord.destroy_tenant(tenant_name) if destroy_existing_tenant + ApplicationRecord.create_tenant(tenant_name) do + @account = Account.create_with_admin_user(queenbee_id: queenbee_account.id) + @account.setup_basic_template + @user = User.first + end + end + + def destroy_tenant + if queenbee_account && ApplicationRecord.tenant_exist?(tenant_name) + ApplicationRecord.destroy_tenant(tenant_name) + @account = nil + @user = nil + end + end + + def destroy_queenbee_account + queenbee_account&.cancel + @queenbee_account = nil + end + + def destroy_signal_identity + SignalId::Database.on_master do + signal_identity&.destroy + end + @signal_identity = nil + end + + def validate_new_identity + unless signal_identity.present? + build_new_identity.tap do |identity| + unless identity.valid? + identity.errors.each { |error| errors.add(error.attribute, error.message) } + end + end + end + end + + def build_new_identity + SignalId::Identity.new do |identity| + identity.name = full_name || email_address + identity.email_address = email_address + identity.username = email_address + identity.password = password + end + end + + def queenbee_account_attributes + { + skip_remote: true, # Fizzy creates its own local account + product_name: "fizzy", + name: queenbee_account_name, + owner_identity_id: signal_identity.id, + trial: false, + subscription: subscription_attributes, + remote_request: request_attributes + } + end + + def subscription_attributes + subscription = FreeV1Subscription + { name: subscription.to_param, price: subscription.price } + end + + def request_attributes + { remote_address: Current.ip_address, user_agent: Current.user_agent, referrer: Current.referrer } + end + + def queenbee_account_name + name = company_name.presence || signal_identity.name + name += " (Beta)" if Rails.env.beta? + name + end +end diff --git a/app/models/subscription.rb b/app/models/subscription.rb new file mode 100644 index 000000000..cf9113b54 --- /dev/null +++ b/app/models/subscription.rb @@ -0,0 +1,9 @@ +class Subscription < Queenbee::Subscription + SHORT_NAMES = %w[ FreeV1 ] +end + +class FreeV1Subscription < Subscription + property :proper_name, "Free Subscription" + property :price, 0 + property :frequency, "yearly" +end diff --git a/app/models/user.rb b/app/models/user.rb index 23b2f1cb2..5e295c4dd 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,5 +1,5 @@ class User < ApplicationRecord - include Accessor, ActionText::Attachable, Assignee, Mentionable, Named, Role, Transferable + include Accessor, ActionText::Attachable, Assignee, Mentionable, Named, Role, SignalUser, Transferable include Timelined # Depends on Accessor has_one_attached :avatar diff --git a/app/models/user/signal_user.rb b/app/models/user/signal_user.rb new file mode 100644 index 000000000..16bee49bc --- /dev/null +++ b/app/models/user/signal_user.rb @@ -0,0 +1,7 @@ +module User::SignalUser + extend ActiveSupport::Concern + + included do + belongs_to :signal_user, dependent: :destroy, class_name: "SignalId::User", optional: true + end +end diff --git a/app/views/sessions/launchpad/show.html.erb b/app/views/sessions/launchpad/show.html.erb new file mode 100644 index 000000000..897e39922 --- /dev/null +++ b/app/views/sessions/launchpad/show.html.erb @@ -0,0 +1 @@ +<%= auto_submit_form_with method: :put %> diff --git a/app/views/signup/accounts/new.html.erb b/app/views/signup/accounts/new.html.erb new file mode 100644 index 000000000..9f69eba4b --- /dev/null +++ b/app/views/signup/accounts/new.html.erb @@ -0,0 +1,58 @@ +<% @page_title = "Sign up for Fizzy" %> + +
" style="--panel-size: 65ch;"> +

Fizzy

+

Create your account

+ + <%= form_with model: @signup, url: signup_accounts_path, scope: "signup", class: "flex flex-column gap txt-large", data: { turbo: false } do |form| %> +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + <% if @signup.errors.any? %> +
+ +
+ <% end %> + + + <% end %> + + +
diff --git a/app/views/signup/completions/new.html.erb b/app/views/signup/completions/new.html.erb new file mode 100644 index 000000000..2fce66a3c --- /dev/null +++ b/app/views/signup/completions/new.html.erb @@ -0,0 +1 @@ +<%= auto_submit_form_with url: signup_completions_path, data: { turbo: false } %> diff --git a/bin/dev b/bin/dev index 4099edaa0..e5fc62f6c 100755 --- a/bin/dev +++ b/bin/dev @@ -2,10 +2,8 @@ echo "Access with david@37signals.com / secret123456 on http://37signals.fizzy.localhost:3006" echo "Access with david@37signals.com / secret123456 on http://honcho.fizzy.localhost:3006" -echo "Access first run on http://first-run.fizzy.localhost:3006" if [ -f tmp/solid-queue.txt ]; then - # see also config/environments/development.rb export SOLID_QUEUE_IN_PUMA=1 fi diff --git a/bin/setup b/bin/setup index c7223d499..61727ea2e 100755 --- a/bin/setup +++ b/bin/setup @@ -7,8 +7,13 @@ system("gem install bundler --conservative") system("bundle check") || system!("bundle install") puts "\n== Preparing database ==" -system! "bin/rails db:prepare" -system! "bin/rails db:reset" if ARGV.include?("--reset") +if ARGV.include?("--reset") + system! "bin/rails db:drop db:create db:schema:load db:prepare" + system! "bin/rails db:fixtures:load" # load signal id fixtures before running fizzy seeds + system! "bin/rails db:seed" +else + system! "bin/rails db:prepare" +end puts "\n== Removing old logs and tempfiles ==" system! "bin/rails log:clear tmp:clear" diff --git a/config/ci.rb b/config/ci.rb index c94990581..bc96a3831 100644 --- a/config/ci.rb +++ b/config/ci.rb @@ -10,6 +10,7 @@ CI.run do step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" step "Tests: Rails", "bin/rails test" + step "Tests: 37id", "bin/rails 37id:test:units" if success? step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" diff --git a/config/credentials/beta.yml.enc b/config/credentials/beta.yml.enc index bff4f4de1..8eb55f4cd 100644 --- a/config/credentials/beta.yml.enc +++ b/config/credentials/beta.yml.enc @@ -1 +1 @@ -/M5cK3B/Wq1R2f4XsQQ8t7qJGCNMaBU3itDDKNZapeGMjrlE4A9CKOh2RPQsKlrsGjJdgGL4YdfDiT1LiF0pD/KL/t5Fw8Z8GMndzA+hmpvQ+hgufvd8n2ZzK/SIK6RmfC9BabdlvuQ0rr3/EdQJ5yIgk3WtWv73bOWDopqHerEAcaNl8KjCu5n2vokAidqEh0/O30yQQB9PAFMRqEwapLVcDSnaJXr6OQnAAm9tIoWaEaIF8ZJH3Rmn0COs8xZqYFyFv/pP3gVy3Mw+VL3aIkEf/eZ3IQWYNf7fiKPvIdNVsmghmeoQRqCp4E0dpn2XDSEGx5i5oq7ynSDCPky4GahiLtpe3vas+Z+lzD5hAAZmlX0G615b4hWjAViTEQU+CMXhmpj7eVpT9co+1oKmqtbDvHr7uCJIf4d17kC13u/8yjgl2SaCjRWx94wh8su/h6h9Xw0qewITbfM32pFRiH+R5bc=--fxg4Lf+qmYeuvyZp--qVZ+ELV0FRzBb69MUAqOsQ== \ No newline at end of file +yD+Wb/DYMXUXf16brcYmNb7OZngx7PuA6gLP8w6ZV7pA/GFweTIXQw67YoXebL6A7gWxhXX4tvpnrz9xPC1p5+5a8q00lOhcybc/480bSfenIXExDpg6mSDbQLRomrpwZsr+IqBWRC8WoXTtpVYRybEPRP88ZXdyE0MjnfOlGcr1fUcF3r9T8biXfNhS9v9UTxck1Q2yzGoNFRvBm3KgBN2QlJocG5AXaDYUSMfLoLmtkicO0KVOD7eYBxe0kDevUK7ZQGxbtATGIsjAJEifhuaPTj/Q2DH134TU4rlPr6n6vm9YEgC/CGVoqPIDgvovVd5IiqZlxGuVDDw3ct0cPH4/AnDLOVHiubi5/pZRCQyeOF+G1OmLVFUVeuZ6alPVrJSxHFxGVM/HT+pLWiReR/IJYacLzb3b2OOtBp/AKxbt25YxFFHuFHABfVEqPRo0qVwbQaxKZEyDyEW0t0ktTkqHywqkbmsx3HPU3skV42rTHf9c1udJhzTXZJPKIWKP96ahgX6eaOhy09rzwD99RnKLJuAuMl80Cw1L+ftLG5hM4tHYmv+JbpWDSInvP1jtA5iWtDRSiqhTxvXw4ItHximvsERI5k6hSPJzrzqnhLUxNVi10KQHh5KGq7EiC2J3HJwioY+fWVivzEJdw2WuVFsovcFyolrT3tyqcMVF5V3dQw4/gOGQ7gXsz0a9sb5wQgXJ9pyZBAmD4Bg7r4wD0f0JiL/5EdKOkAOTV2JDt0OgoSWzU+yuvByuWxPn2quoX0ZSG0huHvl2NK33RoR9aXLcSjtmi5XJGXpjATfKMoA2d8JBIDiJcOPAPwWkpI7AOnmvlV2SmrqPlLTkO0MfdJ528k8CFi2puSRQN+CEPhyhZclDO/J2IP94qmE35xAmWM7yjY6E+EZY9uhlF39uBhAIXylZwTyF31PSCqwpQJthH62pDFWpCgjy1iax482cCVt5n2HLQpmSNaPAvsI7hZAGFJsrDncwfJiK68YXrgmTeg8=--Gd2i+KrusH11oais--SraJ0tcCfEgIdRSJYoCoNw== \ No newline at end of file diff --git a/config/credentials/development.yml.enc b/config/credentials/development.yml.enc index affc4c6cd..ce2cc3c0f 100644 --- a/config/credentials/development.yml.enc +++ b/config/credentials/development.yml.enc @@ -1 +1 @@ -e6HcyVNJvgdJGTCcvW8FDHQ9pHYmTgNW2/71Espdwxd5LQX/Xb9+VkeqpEAL8ceFJo6ro49mMs85G2YngKbbXEh//8+XSS0inMR/kh9wTV3f6YBWnLVBbhrOnYgPcQ8vedsgxLG2Cs4y7ipfeT/dp9bb5m4IxV0OPiUjVFFJuAuGkMJt3pE7D6alqL1ACRxazV9UKV5WKEBfaQYeKpJ++5f86Nls1EwKo5tB1lJed9P5n2yH7meX/QyxjgQd38CIaPwp+F92y5DLKJD5MZRMJGgWf6MT6096LhjEC3vvtS3wwT/eDVcVKTplRkEszj7BZEfOs9Ondb9EVtbXmeoHYSgpaOq9/nEwtho5Uw==--KlHZy1apg3u1XxFs--N2ehRobLHNz+Sepc8p4EIA== \ No newline at end of file +hHi9VfqfZI57GYR5r75Rh00L3FYeoUgsdtCgBzJGdPhhGZLGlCgUAcY6tgVhqhgM/u5WxyPAzmoV+bbQKb0wO0U6KUmrDKDv9uRnNSUjXGYsg8gWdTUTfx7WBFOFawV3p1Dsm7zM1oUJbRWgy2TiMFl5kP9CN4Lk78n5a1/Y7mnx/wmzwUyQv+BUFJ0UKCfRmWieaNoLkm5kWwyepWHaSRU5EVda6J9VtuwP3hISIzncXm0jpr9r/tJm8flgUR3o2STIVHVgg+YcWz0j3st5RMv7i3D2OHfNGx84vsf3LNlsiLGB1ZU0IsmchLOSqPmlDK2oiB7UKepTPthGUFzNmcDG4LpUQfNwn7B8P7by0zadVefdLSB3kFCNLX+KCAbcjHPlWufkagwHo+dV+iIVACC+hfWl3L/IouqYS0ln2XCBDz6A8lI8PHeGaJe9UhfA5JEuJLRPui8znoq7LrKo4/oBB7ArKdHz/W/oHJA9+aLHcLOq9TPSExAsGi1wcjdgF0nk5mB9iX+az3//1zM64rRz8EyzNkBg1I6PV6wpzROLubttv1+I5nRVe4BernNfVJPi/hbvrlrcZmouU32oOzd2aZuPL5/H8gBhMOPlN25Rr51XCziaczgdEfOwTO8da6r7pxsJDkrvKi+Ju6efxhZ6GeotWJuEUtQtWhHnzvhcJAXfUtJf6A==--LWtZauLkWq+tD+4L--mkMcqYebO9TDi6RTRNUQvA== \ No newline at end of file diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc index ef3036025..e97e68755 100644 --- a/config/credentials/production.yml.enc +++ b/config/credentials/production.yml.enc @@ -1 +1 @@ -Qkkt3krcGlOh76//7TmY5rXz31mhL3NNNiO6TCcRhx4lT6uZ8sLjW7X0yp2wZ30y2cJBeIsNMeOhL47JTpu0Z4d0FPtwgH4bzzXAiab0/YrfMjxyBt7/K1DzW/bePUvakJggIvVGGRA1NB1tPkwziQ4dgA34E/UUPAxUofFaoCIFVABmqt4DFg0DKYB8tImDDjZ5xUfSNxrKj3pH5mOIU9jqSK6LtBELFZcNxrJOI0UEgayRNwYAs44ujdM2/2zrAWlUCFZ3NnMU4bBJW02/d6LaxHtB809u8mx3bChEzWMtZB9CvpbRH7u6XfZJR1YGFztcNguzRbNX5x9pKZImAuAhVFKR7Fbi12Hg3w7U3A/YovwmyhHK6ksfOzYlYoDUTBnGYVwiqR16naaHCMekomK+uo0qtOn83RQkLuuqFJshPXn/lW8Rggt4dq/0THEl7Qj+dumadlWDVEgxhipI031RSBF1toNWw/zwaC31Ord1YXD8ZC09sPoj8Zd4hD2o2a0IUuIMXA8w5/2+AmdU8wF66opdKGdsZDRjM2Bul8aAbJ7/iV7xTuvlE4LmzXGArMvivIZ0GwrN3YAsDta2Y3ufibq7WZzSCOrpbPKJ9CEGg4a2AN8UGaGZ5ZEAYg8lhDgsZMjHRkGNuxk8QkDiVFDyNoI7H9ZQqbw+L1AJRTfDIcNB/9h5n+//Nk4ylLF1lnI7y/IQA/AuSQ+UN+okt3RhLugwGvWrs49MExJStLHpGveSoQ==--MQ2JycR3X6LIiKSY--45C7bleKX9FMGv4fc+G1yw== \ No newline at end of file +cPaSSbJ2XWIjNXDRi25iIH41V4zR1fKVTe3OV1XwZKsDE3950DL9Sk0RI/1bxYY+agT3x88vrsqm8xyqLwqcMeMg7J4el52WI4hORe5XLGJTcvs8CTgguut1s7c5Jb5ihOSPX9ZYEKnONxLU2BFe951WZgO+7Us2IBFbCQiqo8PXEBEONePuE9Aw2z1fUFW7NvxhptN9ws7DptP2O/wpHP8+KJmVuAJl+/qJHJoBh770dwQU3yCFz9mERowkARK4Z16Q+HelkrkvW9UziV53tMIExDRvfMnEKJz4wrU/AqVSiQIMQA6eVfTMQLxbVhBhLAlcV0Qic7yEbXLcAXwLX6Kga6aMOqyJvxh+JUnMpQavtHKikVncN8EI1lGxd/+mddS+khHZmCqkRvWl+LiaOe2WOkNENYXlGYANeRSTDy2BqMeT1ymAj3YxfWKYdR34Ukmm2iZKQ1d6fF+Dg/IT8/W/rIZPajQjMTO05WqeoE8kZ4lhgI7AxZHg9apHGsfkq6iBaf4kZUBFiLgOv0CR4Xz3zWVUHEyPx5afkkxs1u9NCspOaYWT4gA1tekFmaH7cMyhaQ/SEjOEs5AWyh47PeMBvwUumJKIEeil6p6K9109nSFG+BPszfhshY8G3aonuIbiWoJQAiGGnbvw4oZeAPjl8XNBYTWRspl2T9ep3krPg9L4eTrAyXdT0dG7GYXSa5NkrV86HugU5rDfx87N4QJVu6qr7Myzw6kEWlLRXHR6HPg+H7qZenNwuOfgnD3YoFOEWQfLiz9U3MqDRJIq5j+i2OfL4pv0VVpc51z42mWM1hvazGNiiTZvK0DohBja6D9h5Gl6ZP/eOGB8O6ZxIwn6mp23BIvxA5jBRGslCj/Op7msnkSAP0vtuI0hnCtfsOx0RfAPvj1/ETjVkRFhM7heWTgfqGQbjSVGvd/Dj6j3vbqGz3RASVL48juoWx/pezUMnWGZqRTqx5tD6FrZQy1yf/2Uoc8yMJQU14raqiShEqNveJswMv1D9kBnT3551DtpAmrMT+iuCX5aZHwVnY/sI8J8HdMAqFfpipMbCbccI/PgTc+U8uOqc+BBKYy8u04wHLnGu5wREewU5xRIkxcwGm4M4LkTLiVEJFUQuFEhylmxkCIDYG+WSBZ5xy/0dRUeZs26Uuvvs2//bXqn75Ia86Fpv54AzfdDqC9s0B1r5/ZqXtFjjL3bQyhv+SP5TMurAZcI0rLYay+oFcLZAv31zoo0dFcufiCKYTjbYEIlO+HW2aeVeOhUnCalqqslt4ql5A==--tVMd1KheRgH8F0gZ--x8Wzmk8JsMuHPG3JMOODLA== \ No newline at end of file diff --git a/config/credentials/staging.yml.enc b/config/credentials/staging.yml.enc index 8387cc6d8..ef3fa3ffb 100644 --- a/config/credentials/staging.yml.enc +++ b/config/credentials/staging.yml.enc @@ -1 +1 @@ -Tc514fYVlm1c2c81KUFmuAGZ9rSZT7XfeGUEdTEvJNDD6JE9IghKbAAGgc6AalIt2/VxzSV6AGvNagb0/qlJrMOFeSJLqzufKt1YoLRUZtSUZDtzmI/2WSzju0Tyu4za6rnLEu+WvDc0NM8KZEv/TwwUbSOwoEVDQmava/iuWyvFr5sOH1wNr2kwYbErwDpThKcYqacI4CdvgpgXc//3T0Z5vsVCs6d8723DymM0KkSK9so8WrcCesk2fmhM1K2hQV7QdckaVuOL0JEj1AI7KzdJm7REoeLwRwaBeVDkqDNo2fKxwZ2XmU2ViJOozPsowx+324wQQZjbsf/0ohtDkgjbfMmA2tjkuNJdJFwIIc5VyvILhVs5WQIJHSXmabhUKxzKRCRZNBSaadLGCb7/AGfULKvh2vP1BWqm80EkDmhHm42ucJW1vA377y9UYWVcdvqWtp5m2F7HlXqi9Uw/+kFV2X6Jw1c07FsCu35TrUb5u8G7mu/866GBgf5VlfDVs1BA3aru05CtcsXi2qKMw0II7fZps/4XpYCB+VehXAyLi2ABVeTnqSfF7HwasXvfD3Jgh7zJLD0hudNDpu6xkmoyeksVBn034dmQf0TwAIKmNjEKMDxgAG50T2JhnEvHZEnqzGl6O8if8DKM5rPgt3XEU3zN/U3yA8T0eTyFdGlWbYK7Ergu4TcGo3WPk6bJhTGOHQ9puD89t6JGHbKDYW77w44SHv+C42flmNkzcndXlpvZvnEafDYeW6ofRC4eeTRX4+WKL4CYhlel78+twD6Mh0MV42MYM2rmtl66NHN8fWxyfoyjrNcXveKVAsGMatB0+dOglq6GmOiXRAt/I6D+7QYZLyaUhYNmmiuZXF4=--H6KWPnHc9qUQE5AI--PN98cg9t9ZhxMUEax/Mfgg== \ No newline at end of file +UV9AzQwwjGoWFh78GyKkNewL4lmsL80XT+6ca0JIxZ632OMKbYzHbPtJAD6RK1gaEB4GRkiBE2mLEdk8nYI47p8K9NVqZuI/ykgl23Sr2JzBpnHfjtUYlPQC7nYyeAR3gYtASD5+jSXvP7z/9BquIVdTvih3hWPPujDK5OROXagDtlGTZ4SgKZwihCTZczTwZRLxx5UguwbtuowrtcuPTg+r/H+zdJyRUU9kF7vYqV5VrP0vj0mXZUf5hWNBpKvhDtqgKUJNU87ds7xTLwMuQT0MCASoJXS0JSpIKn653baXczQue6JyuFcOlqDPDmBWs2GLh6pq3XnNolmWoyNehD9WClmJ5cu0l1dPmYZ0WnOfPgJCPtuJI4onIUj2nLC5m0tHpQPNs9WjazQeCeiw7TJBESK3b3lB4/Q7STshfJd74IIJ0crzfm0a6MwxvT184etKpNRQkgJpwK6WRNPzzzHdlTmbkEher4ilcs3QrmyLPYaLcuG+0YyDcjgJXRvLumkZilONbuBHluMc64u4GAU8QyW1dp31DrCw/HTOTtPEsRJSKdTIiFFCDpYt0Uq16Qbp9Hsg1ruPqFPELXDqRSl+N9ixDdXjF8WyQc6Gx4i8Fsur0WBLGBEtCEkCCQvwsH+TVH47S68CSiUui0c2zX1sM2SEcOOL7Cn0TUDWg6d8MgHQ5977aGLGFUkeuldv8KfQSra5uHKESSxOwn/eJJQMERSiVHSEj1EK3v4R+shXnviPsFTr3uERjdn2s1CcSk3qJ7J5WFpmgTL3/qKRWDroI96QO24CSr8JWJ3MzrwkLLpiEf6qLyfswiRjJ79sxk1pTdx7zedb8MuG/596J/xFI7GdbOI6qr2NvVUHH6r6PqXltMBoT/JU4pO7QOXfuok8zYGOj2gMzktCOdvEoydh+8QrsKN6W3mpg8oweQoq/2tgRm/YTeJMz+MQMR+J+aid2lSu8/wiHtsv8WdxJ7qxI7G3DslzbTEz0e3FAP+CEFOVgwN+nitWZpSyst6WiupzU5Ou9NxYiWbmhy5nhDJFC+MiLs8bS2kWfq3z1/Wsuyp8hBn/T0+MYtQ/cRnAxTE0sUZMB5xpffuGPlx3lgrmxV7wlPAzGooCGkGVUQQ29ljYMoGWqW34EAniIYHLUnBfdS+bNbui5rxVEAFyD4r57eqEaYYOKCFwkhNfYhHUeV+noDQ8uvCzppvRHGKJrkUCS0/A6z4KP8iW8wsETMBX/ShvFf6m44KNGR4IPzfn+r7uO9Pbukit3RJ8RsHZcC3RmH7/gtO3vW8G1BBrT5nI6BdRp9a9LdTx2qn32cKQD7Wc7S1KyFYn5reWSewlxNHHmgy4R/pEXTtdZJ5tuzweeYK/jr78CTrgEEN/btNawNh6SdTwRls5lu33LAQ=--3mgI5AzJFOeaM6Y1--KgIowEbtW/iHXiDrWGk2ug== \ No newline at end of file diff --git a/config/credentials/test.yml.enc b/config/credentials/test.yml.enc index 24d6d9c49..8c7aab16b 100644 --- a/config/credentials/test.yml.enc +++ b/config/credentials/test.yml.enc @@ -1 +1 @@ -1QS4QGl05L2YvkEusirttYv2gIfRbNLEWTK7pOXG/HxqyWbWj9o5/GbTFeLI7K0sbcEjz+gcPcVKB+FGnkJECExynSvIN02bg5gP0QegTC3WA2vWInlr+gpoWCNgsazOgHKMxDBnQXc2myqhwDLKRsDHCQTW/5lIiKGBtBOLaml8KrJg84SeYCh54tJUjAp5yRkrhJQBHVgSSUfXRaFcrmdbPls5FDgB+rErACpp4BgYdXZoDds2RITDgQ==--kpoxQbOswL3cInhK--r+dB3hJHvjfUH1EBNzBLSg== \ No newline at end of file +0EWG0dpq/qELmTCqAgzEL2BZH7JCNFe6DDIynyKE06qiBCYhHs5+fMjmpzN0uwz43tqAAKJdhO/hZY4jXmTpxu+LSjwrUKAlxsWvSGu4vHEbJ5Fe+BzhnS84OFse6ma9akUAHaed3gt31wtrt2Y4AbSPedNvriqfYln7cvpgWbvjTgtEbHrcNMfjCBnaXXdO3n1zILunILgLiV7oBW0qurIrl2xOc4mbj4AqVAIElyh0md9fFtuBfsaRj2U9x/+7McbgTFODhHfeqoErEw7fc1RBFrNi2YNuHBSSqegQ6WtWbwq1Yg2Juv/Lw3g4IIdWadJsW3TdTMMiwmBMzOikx3lNX1Z399glvGNFVnJfBubKf6BxvFfAQ85PBzOZPl+/PTs=--GY44JMs5ZO1/eL6G--EhxEWuitiex6eHROpPQamw== \ No newline at end of file diff --git a/config/initializers/queenbee.rb b/config/initializers/queenbee.rb new file mode 100644 index 000000000..eda1c0048 --- /dev/null +++ b/config/initializers/queenbee.rb @@ -0,0 +1,6 @@ +Queenbee.host_app = Fizzy + +Rails.application.config.to_prepare do + Queenbee::Subscription.short_names = Subscription::SHORT_NAMES + Queenbee::ApiToken.token = Rails.application.credentials.dig(:queenbee_api_token) +end diff --git a/config/initializers/signal_id.rb b/config/initializers/signal_id.rb new file mode 100644 index 000000000..c8804fda5 --- /dev/null +++ b/config/initializers/signal_id.rb @@ -0,0 +1,19 @@ +require "signal_id" + +ENV["SIGNAL_ID_SECRET"] = Rails.application.credentials.signal_id_secret + +Rails.application.config.to_prepare do + SignalId.product = "fizzy" + + SignalId::Database.load_configuration SignalId::Database.default_configuration + SignalId::Database.enable_rw_splitting! + + silence_warnings do + SignalId::Account::Peer = Account + SignalId::User::Peer = User + end +end + +Rails.application.config.after_initialize do + ActiveRecord.yaml_column_permitted_classes << SignalId::PersonName +end diff --git a/config/initializers/tenanting.rb b/config/initializers/tenanting.rb index 78e5d8e05..85ef97ba7 100644 --- a/config/initializers/tenanting.rb +++ b/config/initializers/tenanting.rb @@ -2,10 +2,17 @@ Rails.application.config.after_initialize do # in production and staging, we're using a two-level subdomain like "tenant.fizzy.37signals.com". # in development and beta it's only a single-level subdomain. Rails.application.config.active_record_tenanted.tenant_resolver = ->(request) do - next nil if request.path == "/up" - tld_length = [ "37signals.com", "37signals-staging.com" ].include?(request.domain) ? 2 : 1 - request.subdomain(tld_length).presence || "return-404" # this is a quick fix for now + + if request.path == "/up" + nil + elsif subdomain = request.subdomain(tld_length).presence + subdomain + elsif request.path =~ %r{^/(queenbee|signup)\b} + nil + else + "return-404" # this is a quick fix for now + end end end diff --git a/config/routes.rb b/config/routes.rb index db0cf3cd6..0e32a0b25 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -85,6 +85,14 @@ Rails.application.routes.draw do scope module: "sessions" do resources :transfers, only: %i[ show update ] end + resource :launchpad, only: %i[ show update ], controller: "sessions/launchpad" + end + + namespace :signup do + get "/" => "accounts#new" + resources :accounts, only: %i[ new create ] + get "/session" => "sessions#create" # redirect from Launchpad after mid-signup authentication + resources :completions, only: %i[ new create ] end resources :users do @@ -150,6 +158,8 @@ Rails.application.routes.draw do root "events#index" + Queenbee.routes(self) + namespace :admin do mount MissionControl::Jobs::Engine, at: "/jobs" end diff --git a/db/migrate/20250611203906_add_queenbee_id_to_accounts.rb b/db/migrate/20250611203906_add_queenbee_id_to_accounts.rb new file mode 100644 index 000000000..8d1d55ad4 --- /dev/null +++ b/db/migrate/20250611203906_add_queenbee_id_to_accounts.rb @@ -0,0 +1,6 @@ +class AddQueenbeeIdToAccounts < ActiveRecord::Migration[8.1] + def change + add_column :accounts, :queenbee_id, :integer # I solemnly swear I will add a "NOT NULL" constraint once it's populated + add_index :accounts, :queenbee_id, unique: true + end +end diff --git a/db/migrate/20250612163028_add_signal_user_id_to_users.rb b/db/migrate/20250612163028_add_signal_user_id_to_users.rb new file mode 100644 index 000000000..5caddaea9 --- /dev/null +++ b/db/migrate/20250612163028_add_signal_user_id_to_users.rb @@ -0,0 +1,6 @@ +class AddSignalUserIdToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :signal_user_id, :integer + add_index :users, :signal_user_id, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index dff0ccb02..0c7504173 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_06_09_102553) do +ActiveRecord::Schema[8.1].define(version: 2025_06_12_163028) do create_table "accesses", force: :cascade do |t| t.integer "collection_id", null: false t.datetime "created_at", null: false @@ -26,7 +26,9 @@ ActiveRecord::Schema[8.1].define(version: 2025_06_09_102553) do t.datetime "created_at", null: false t.string "join_code" t.string "name", null: false + t.integer "queenbee_id" t.datetime "updated_at", null: false + t.index ["queenbee_id"], name: "index_accounts_on_queenbee_id", unique: true end create_table "action_text_rich_texts", force: :cascade do |t| @@ -334,9 +336,11 @@ ActiveRecord::Schema[8.1].define(version: 2025_06_09_102553) do t.string "name", null: false t.string "password_digest" t.string "role", default: "member", null: false + t.integer "signal_user_id" t.datetime "updated_at", null: false t.index ["email_address"], name: "index_users_on_email_address", unique: true t.index ["role"], name: "index_users_on_role" + t.index ["signal_user_id"], name: "index_users_on_signal_user_id", unique: true end create_table "watches", force: :cascade do |t| diff --git a/db/schema_cache.yml b/db/schema_cache.yml index 89fca8331..2c6a24735 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -113,6 +113,16 @@ columns: default_function: collation: comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: queenbee_id + cast_type: *1 + sql_type_metadata: *2 + 'null': true + default: + default_function: + collation: + comment: - *9 action_text_rich_texts: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column @@ -983,6 +993,16 @@ columns: default_function: collation: comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: signal_user_id + cast_type: *1 + sql_type_metadata: *2 + 'null': true + default: + default_function: + collation: + comment: - *9 watches: - *22 @@ -1163,7 +1183,23 @@ indexes: nulls_not_distinct: comment: valid: true - accounts: [] + accounts: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: accounts + name: index_accounts_on_queenbee_id + unique: true + columns: + - queenbee_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true action_text_rich_texts: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: action_text_rich_texts @@ -2250,6 +2286,22 @@ indexes: nulls_not_distinct: comment: valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: users + name: index_users_on_signal_user_id + unique: true + columns: + - signal_user_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true watches: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: watches @@ -2301,4 +2353,4 @@ indexes: comment: valid: true workflows: [] -version: 20250609102553 +version: 20250612163028 diff --git a/db/seeds.rb b/db/seeds.rb index 9af00fe66..a04ee160e 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -7,24 +7,52 @@ def seed_account(name) puts " #{elapsed.round(2)} sec" end -def create_tenant(name) - ApplicationRecord.destroy_tenant name - ApplicationRecord.create_tenant name - ApplicationRecord.current_tenant = name +def create_tenant(signal_account_name) + signal_account = SignalId::Account.find_by_product_and_name!("fizzy", signal_account_name) + tenant_name = signal_account.subdomain + + ApplicationRecord.destroy_tenant tenant_name + ApplicationRecord.create_tenant(tenant_name) do + account = Account.create_with_admin_user(queenbee_id: signal_account.queenbee_id) + account.setup_basic_template + end + + ApplicationRecord.current_tenant = tenant_name end -def create_first_run(name, email_address, password: "secret123456") - FirstRun.create!(name:, email_address:, password:) +def find_or_create_user(full_name, email_address) + SignalId::Database.on_master do + unless signal_identity = SignalId::Identity.find_by_email_address(email_address) + signal_identity = SignalId::Identity.create!( + name: full_name, + email_address: email_address, + username: email_address, + password: "secret123456" + ) + end + + signal_account = Account.first.signal_account + signal_user = SignalId::User.find_or_create_by!(identity: signal_identity, account: signal_account) + + if user = User.find_by(signal_user_id: signal_user.id) + user.password = "secret123456" + user.save! + user + else + User.create!( + signal_user_id: signal_user.id, + name: signal_identity.name, + email_address: signal_identity.email_address, + password: "secret123456" + ) + end + end end def login_as(user) Current.session = user.sessions.create end -def create_user(name, email_address, password: "secret123456") - User.create!(name:, email_address:, password:) -end - def create_collection(name, creator: Current.user, all_access: true, access_to: []) Collection.create!(name:, creator:, all_access:).tap { it.accesses.grant_to(access_to) } end @@ -36,4 +64,3 @@ end # Seed accounts seed_account "37signals" seed_account "honcho" -seed_account "first-run" diff --git a/db/seeds/37signals.rb b/db/seeds/37signals.rb index 432030bbc..2bc322fdc 100644 --- a/db/seeds/37signals.rb +++ b/db/seeds/37signals.rb @@ -1,10 +1,10 @@ create_tenant "37signals" -david = create_first_run "David Heinemeier Hansson", "david@37signals.com" -login_as david +david = find_or_create_user "David Heinemeier Hansson", "david@37signals.com" +jz = find_or_create_user "Jason Zimdars", "jz@37signals.com" +kevin = find_or_create_user "Kevin Mcconnell", "kevin@37signals.com" -jz = create_user "Jason Zimdars", "jz@37signals.com" -kevin = create_user "Kevin Mcconnell", "kevin@37signals.com" +login_as david create_collection("Fizzy", access_to: [ jz, kevin ]).tap do |fizzy| create_card("Prepare sign-up page", description: "We need to do this before the launch.", collection: fizzy) diff --git a/db/seeds/first-run.rb b/db/seeds/first-run.rb deleted file mode 100644 index e6df219d9..000000000 --- a/db/seeds/first-run.rb +++ /dev/null @@ -1 +0,0 @@ -create_tenant "first-run" diff --git a/db/seeds/honcho.rb b/db/seeds/honcho.rb index 34599c600..939dc9147 100644 --- a/db/seeds/honcho.rb +++ b/db/seeds/honcho.rb @@ -1,15 +1,15 @@ -create_tenant "honcho" +create_tenant "Honcho" + +david = find_or_create_user "David Heinemeier Hansson", "david@37signals.com" +jason = find_or_create_user "Jason Zimdars", "jz@37signals.com" +kevin = find_or_create_user "Kevin McConnell", "kevin@37signals.com" +jorge = find_or_create_user "Jorge Manrubia", "jorge@37signals.com" +mike = find_or_create_user "Mike Dalessio", "mike@37signals.com" -david = create_first_run "David Heinemeier Hansson", "david@37signals.com" login_as david -jason = create_user "Jason Zimdars", "jz@37signals.com" -kevin = create_user "Kevin Mcconnell", "kevin@37signals.com" -sarah = create_user "Sarah Johnson", "sarah@37signals.com" -mike = create_user "Mike Peterson", "mike@37signals.com" - # Array of authors for random assignment -authors = [ david, jason, kevin, sarah, mike ] +authors = [ david, jason, kevin, jorge, mike ] # Card titles for reuse across collections card_titles = [ diff --git a/script/bootstrap-signal-id.rb b/script/bootstrap-signal-id.rb new file mode 100755 index 000000000..7618e8988 --- /dev/null +++ b/script/bootstrap-signal-id.rb @@ -0,0 +1,117 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" + +ActiveRecord::Base.logger = Logger.new(File::NULL) + +class BootstrapSignalId + def initialize(dry_run: false) + @dry_run = dry_run + end + + def run + SignalId::Database.on_master do + ApplicationRecord.with_each_tenant do |tenant| + puts "\n# tenant: #{tenant}" + + next unless check_account_preconditions + create_signal_id_account if Account.first.queenbee_id.nil? + + create_signal_id_users + end + end + end + + def check_account_preconditions + unless Account.count == 1 + puts "There are #{Account.count} accounts, but exactly one is expected." + false + else + true + end + end + + def create_signal_id_account + owner = SignalId::Identity.find_by_email_address!("kevin@37signals.com") + print_identity("New owner is:", owner) + + unless @dry_run + qbattr = queenbee_account_attributes(owner) + queenbee_account = Queenbee::Remote::Account.create!(qbattr) + + signal_id_account = SignalId::Account.find_by!(queenbee_id: queenbee_account.id) + signal_id_account.update_column :subdomain, ApplicationRecord.current_tenant + + account = Account.first + account.queenbee_id = queenbee_account.id + account.name = ApplicationRecord.current_tenant + account.save! + end + end + + def create_signal_id_users + signal_account = Account.first.signal_account + + User.find_each do |user| + if !user.system? && user.signal_user_id.nil? + signal_identities = SignalId::Identity.where(email_address: user.email_address) + if signal_identities.length > 1 + puts "Multiple identities found for #{user.email_address}:" + signal_identities.each { |identity| print_identity(" - ", identity) } + signal_identity = signal_identities.first + elsif signal_identities.length == 1 + signal_identity = signal_identities.first + print_identity("Identity for #{user.email_address}:", signal_identity) + else + puts "No identity found for #{user.name} (#{user.email_address})" + signal_identity = nil + end + + if signal_identity + unless @dry_run + signal_user = SignalId::User.find_or_create_by!(identity: signal_identity, account: signal_account) + + user.signal_user_id = signal_user.id + user.save! + end + end + end + end + end + + def print_identity(message = "Identity:", identity) + pad = " " * message.length + puts "#{message} #{identity.name} (#{identity.email_address})" + puts "#{pad } ID: #{identity.id}" + puts "#{pad } Username: #{identity.username}" + end + + def queenbee_account_attributes(signal_identity) + { + skip_remote: true, # Fizzy creates its own local account + product_name: "fizzy", + name: account_name, + owner_identity_id: signal_identity.id, + trial: false, + subscription: subscription_attributes, + remote_request: request_attributes + } + end + + def subscription_attributes + subscription = FreeV1Subscription + { name: subscription.to_param, price: subscription.price } + end + + def request_attributes + { user_agent: "script/bootstrap-signal-id.rb" } + end + + def account_name + name = ApplicationRecord.current_tenant + name += " (Beta)" if Rails.env.beta? + name + end +end + +BootstrapSignalId.new(dry_run: false).run diff --git a/test/controllers/sessions/launchpad_controller_test.rb b/test/controllers/sessions/launchpad_controller_test.rb new file mode 100644 index 000000000..2ab50b2a0 --- /dev/null +++ b/test/controllers/sessions/launchpad_controller_test.rb @@ -0,0 +1,30 @@ +require "test_helper" + +class Sessions::LaunchpadControllerTest < ActionDispatch::IntegrationTest + test "show renders when not signed in" do + get session_launchpad_path(params: { sig: "test-sig" }) + + assert_response :success + + assert_equal cookies[:_fizzy_launchpad_sig], "test-sig" + end + + test "create establishes a session when the sig is valid" do + user = users(:david) + cookies[:_fizzy_launchpad_sig] = user.signal_user.perishable_signature + + put session_launchpad_path + + assert_redirected_to root_url + assert parsed_cookies.signed[:session_token] + end + + test "returns 401 when the sig is invalid" do + user = users(:david) + cookies[:sig] = "not-valid" + + put session_launchpad_path + + assert_response :unauthorized + end +end diff --git a/test/controllers/signup/accounts_controller_test.rb b/test/controllers/signup/accounts_controller_test.rb new file mode 100644 index 000000000..270edab58 --- /dev/null +++ b/test/controllers/signup/accounts_controller_test.rb @@ -0,0 +1,108 @@ +require "test_helper" + +class Signup::AccountsControllerTest < ActionDispatch::IntegrationTest + test "new under a tenanted domain redirects to the root" do + get new_signup_account_url + + assert_redirected_to root_url + end + + test "new under an untenanted domain is OK" do + integration_session.host = "example.com" # no subdomain + + get new_signup_account_url, headers: auth_headers + + assert_response :success + end + + test "create with invalid params" do + integration_session.host = "example.com" # no subdomain + + post signup_accounts_url, + headers: auth_headers, + params: { signup: { full_name: "Jim", email_address: "jim@example.com", password: "", company_name: "" } } + + assert_response :unprocessable_entity + assert_select "div.alert--error", text: /you need to choose a password/i + end + + test "create for a new " do + integration_session.host = "example.com" # no subdomain + + assert_difference -> { SignalId::Identity.count }, +1 do + assert_difference -> { SignalId::Account.count }, +1 do + post signup_accounts_url, headers: auth_headers, + params: { + signup: { + full_name: "Jim", + email_address: "jim@example.com", + password: SecureRandom.hex(12), + company_name: "signup-accounts-controller-test-1" + } + } + end + end + + signal_account = SignalId::Account.last + assert_redirected_to(/#{signal_account.login_url}/) + end + + test "create for an existing identity" do + integration_session.host = "example.com" # no subdomain + + identity = signal_identities(:david) + + post signup_accounts_url, headers: auth_headers, + params: { signup: { email_address: identity.email_address, company_name: "signup-accounts-controller-test-2" } } + + assert_authentication_requested_for identity + + assert_no_difference -> { SignalId::Identity.count } do + assert_difference -> { SignalId::Account.count } do + authenticate_via_launchpad_as(identity) + assert_redirected_to_account + assert_nil session[:signup] + end + end + + signal_account = SignalId::Account.last + ApplicationRecord.with_tenant(signal_account.subdomain) do + assert_equal Account.last, signal_account.peer + end + end + + test "actions require HTTP basic authentication while we're in internal-only mode" do + integration_session.host = "example.com" # no subdomain + + get new_signup_account_url + + assert_response :unauthorized + end + + private + def auth_headers + { + "HTTP_AUTHORIZATION" => ActionController::HttpAuthentication::Basic.encode_credentials( + Rails.application.credentials.dig(:account_signup_http_basic_auth, :name), + Rails.application.credentials.dig(:account_signup_http_basic_auth, :password) + ) + } + end + + def assert_authentication_requested_for(identity) + assert_response :redirect + assert_match(/#{Regexp.escape(Launchpad.url("/authenticate", login_hint: identity.email_address))}.*&purpose=signup/, redirect_to_url) + end + + def authenticate_via_launchpad_as(identity) + get signup_session_url, headers: auth_headers, params: { sig: identity.perishable_signature } + assert_equal identity.id, session[:signup]["identity_id"] + assert_redirected_to new_signup_completion_url + post signup_completions_url, headers: auth_headers + end + + def assert_redirected_to_account(signal_account = SignalId::Account.last) + assert_response :redirect + assert_match(/#{Regexp.escape(signal_account.url("/session/launchpad"))}.*&sig=/, redirect_to_url) + end +end diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml index 587f05a44..d394aedc3 100644 --- a/test/fixtures/accounts.yml +++ b/test/fixtures/accounts.yml @@ -1,3 +1,4 @@ 37s: name: 37signals join_code: "ejpP-THlQ-Cc2f" + queenbee_id: <%= ActiveRecord::FixtureSet.identify :'37s_fizzy' %> diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index d12c1a4b3..3339d12d6 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -5,18 +5,21 @@ david: email_address: david@37signals.com password_digest: <%= digest %> role: member + signal_user: 37s_fizzy_david jz: name: JZ email_address: jz@37signals.com password_digest: <%= digest %> role: member + signal_user: 37s_fizzy_jzimdars kevin: name: Kevin email_address: kevin@37signals.com password_digest: <%= digest %> role: admin + signal_user: 37s_fizzy_kevin system: name: System diff --git a/test/models/account/signal_account_test.rb b/test/models/account/signal_account_test.rb new file mode 100644 index 000000000..cee8ba0bf --- /dev/null +++ b/test/models/account/signal_account_test.rb @@ -0,0 +1,39 @@ +require "test_helper" + +class Account::SignalAccountTest < ActiveSupport::TestCase + # # TODO(MIKE): Queenbee client API compliance tests + # include Queenbee::Testing::Client + + setup do + @account = accounts("37s") + end + + test "belongs to a signal_account via a shared queenbee_id" do + assert_not_nil @account.queenbee_id + assert_equal @account.queenbee_id, Account.new(signal_account: @account.signal_account).queenbee_id + assert_equal @account.signal_account, Account.new(queenbee_id: @account.queenbee_id).signal_account + end + + test ".create_with_admin_user creates a new local account and user peers" do + ApplicationRecord.create_tenant("account-create-with-dependents") do + signal_account = signal_accounts(:honcho_fizzy) + account = Account.create_with_admin_user(queenbee_id: signal_account.queenbee_id) + + assert_not_nil account + assert account.persisted? + assert_equal 1, Account.count + assert_equal signal_account.queenbee_id, account.queenbee_id + assert_equal signal_account.name, account.name + assert_equal account, signal_account.peer + + assert_equal 1, User.count + User.first.tap do |user| + assert signal_account.owner.name, user.name + assert signal_account.owner.email_address, user.email_address + assert signal_account.owner.id, user.signal_user_id + assert_equal "admin", user.role + assert_equal user, signal_account.owner.peer + end + end + end +end diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 3f86f1cd4..94d4f83dd 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -1,7 +1,4 @@ require "test_helper" -class OrganizationTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end +class AccountTest < ActiveSupport::TestCase end diff --git a/test/models/signup_test.rb b/test/models/signup_test.rb new file mode 100644 index 000000000..fc9f89d7a --- /dev/null +++ b/test/models/signup_test.rb @@ -0,0 +1,149 @@ +require "test_helper" + +class SignupTest < ActiveSupport::TestCase + setup do + @signup = Signup.new( + email_address: "brian@example.com", + full_name: "Brian Wilson", + company_name: "Beach Boys", + password: SecureRandom.hex(16) + ) + end + + test "#to_h allows persistence of the signup data" do + actual = @signup.to_h + expected = { + email_address: @signup.email_address, + full_name: @signup.full_name, + company_name: @signup.company_name + } + assert_equal expected, actual + end + + test "#process creates all the necessary objects for a new identity" do + Account.any_instance.expects(:setup_basic_template).once + + assert @signup.process, @signup.errors.full_messages.to_sentence(words_connector: ". ") + assert_empty @signup.errors + + assert @signup.signal_identity + assert @signup.signal_identity.persisted? + + assert @signup.queenbee_account + assert @signup.queenbee_account.id + + assert @signup.signal_account + assert @signup.signal_account.persisted? + assert_equal @signup.signal_identity, @signup.signal_account.owner.identity + assert_equal @signup.company_name, @signup.signal_account.name + + assert @signup.account + assert @signup.account.persisted? + + assert @signup.user + assert @signup.user.persisted? + assert_equal @signup.user.signal_user, @signup.signal_account.owner + + assert_includes ApplicationRecord.tenants, @signup.signal_account.subdomain + end + + test "the new account is named with company name if present" do + assert_equal @signup.company_name, @signup.send(:queenbee_account_name) + end + + test "in beta, the new account is named distinctly" do + Rails.stubs(:env).returns(stub(beta?: true)) + + assert_equal "#{@signup.company_name} (Beta)", @signup.send(:queenbee_account_name) + end + + test "#process creates all the necessary objects for an existing identity" do + signal_identity = SignalId::Identity.find_by_email_address("david@37signals.com") + + @signup = Signup.new( + signal_identity: signal_identity, + company_name: "Team LeMans", + ) + + Account.any_instance.expects(:setup_basic_template).once + + assert @signup.process, @signup.errors.full_messages.to_sentence(words_connector: ". ") + assert_empty @signup.errors + + assert @signup.signal_identity + assert_equal signal_identity, @signup.signal_identity + + assert @signup.queenbee_account + assert @signup.queenbee_account.id + + assert @signup.signal_account + assert @signup.signal_account.persisted? + assert_equal @signup.signal_identity, @signup.signal_account.owner.identity + + assert @signup.account + assert @signup.account.persisted? + + assert @signup.user + assert @signup.user.persisted? + assert_equal @signup.user.signal_user, @signup.signal_account.owner + + assert_includes ApplicationRecord.tenants, @signup.signal_account.subdomain + end + + test "#process does nothing if a validation error occurs creating identity" do + @signup.password = "" + + assert_not @signup.process + assert_not_empty @signup.errors[:password] + + assert_nil @signup.signal_identity + assert_nil @signup.queenbee_account + assert_nil @signup.account + assert_nil @signup.user + end + + test "#process does nothing if a validation error occurs creating the queenbee account" do + Queenbee::Remote::Account.stubs(:create!).raises(RuntimeError, "Invalid account data") + + SignalId::Identity.any_instance.expects(:destroy).once + + assert_not @signup.process + assert_not_empty @signup.errors[:base] + + assert_nil @signup.signal_identity + assert_nil @signup.queenbee_account + assert_nil @signup.account + assert_nil @signup.user + end + + test "#process does nothing if a validation error occurs creating the tenant" do + ApplicationRecord.stubs(:create_tenant).raises(RuntimeError, "Tenant already exists") + + Queenbee::Remote::Account.any_instance.expects(:cancel).once + SignalId::Identity.any_instance.expects(:destroy).once + + assert_not @signup.process + assert_not_empty @signup.errors[:base] + + assert_nil @signup.signal_identity + assert_nil @signup.queenbee_account + assert_nil @signup.account + assert_nil @signup.user + end + + test "#process does nothing if a validation error occurs creating the account" do + Account.stubs(:create_with_admin_user).raises(RuntimeError, "Account creation failed") + + ApplicationRecord.expects(:destroy_tenant).once + Queenbee::Remote::Account.any_instance.expects(:cancel).once + SignalId::Identity.any_instance.expects(:destroy).once + + assert_not @signup.process + assert_not_empty @signup.errors[:base] + + assert_nil @signup.signal_identity + assert_nil @signup.queenbee_account + assert_nil @signup.account + assert_nil @signup.user + end +end diff --git a/test/models/user/signal_user_test.rb b/test/models/user/signal_user_test.rb new file mode 100644 index 000000000..81071517b --- /dev/null +++ b/test/models/user/signal_user_test.rb @@ -0,0 +1,12 @@ +require "test_helper" + +class User::SignalUserTest < ActiveSupport::TestCase + setup do + @user = users(:david) + end + + test "belongs to a Signal::User" do + assert_not_nil @user.signal_user_id + assert_equal signal_users("37s_fizzy_david"), @user.signal_user + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 2cce7f8d6..622c41a0e 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -3,6 +3,9 @@ require_relative "../config/environment" require "rails/test_help" require "webmock/minitest" require "vcr" +require "signal_id/testing" +require "queenbee/testing/mocks" +require "mocha/minitest" WebMock.allow_net_connect! @@ -25,6 +28,7 @@ module ActiveSupport fixtures :all include ActiveJob::TestHelper + include SignalId::Testing include ActionTextTestHelper, CardTestHelper, ChangeTestHelper, SessionTestHelper end end @@ -32,3 +36,11 @@ end RubyLLM.configure do |config| config.openai_api_key ||= "DUMMY-TEST-KEY" # Run tests with VCR without having to configure OpenAI API key locally. end + +Queenbee::Remote::Account.class_eval do + # because we use the account ID as the tenant name, we need it to be unique in each test to avoid + # parallelized tests clobbering each other. + def next_id + super + Random.rand(1000000) + end +end