whop 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +58 -0
  3. data/config/routes.rb +5 -0
  4. data/examples/rails_app/template.rb +66 -0
  5. data/lib/generators/whop/discover_page/discover_page_generator.rb +23 -0
  6. data/lib/generators/whop/discover_page/templates/discover_controller.rb +6 -0
  7. data/lib/generators/whop/discover_page/templates/show.html.erb +6 -0
  8. data/lib/generators/whop/install/install_generator.rb +23 -0
  9. data/lib/generators/whop/install/templates/whop.rb +10 -0
  10. data/lib/generators/whop/install/templates/whop_iframe.rb +9 -0
  11. data/lib/generators/whop/scaffold/all/all_generator.rb +22 -0
  12. data/lib/generators/whop/scaffold/company/company_generator.rb +26 -0
  13. data/lib/generators/whop/scaffold/company/templates/companies_controller.rb +17 -0
  14. data/lib/generators/whop/scaffold/company/templates/show.html.erb +7 -0
  15. data/lib/generators/whop/scaffold/experience/experience_generator.rb +26 -0
  16. data/lib/generators/whop/scaffold/experience/templates/experiences_controller.rb +17 -0
  17. data/lib/generators/whop/scaffold/experience/templates/show.html.erb +7 -0
  18. data/lib/generators/whop/webhooks/handler/handler_generator.rb +17 -0
  19. data/lib/generators/whop/webhooks/handler/templates/job.rb +10 -0
  20. data/lib/generators/whop/webhooks/install/install_generator.rb +15 -0
  21. data/lib/whop/access.rb +37 -0
  22. data/lib/whop/client.rb +177 -0
  23. data/lib/whop/controller_helpers.rb +51 -0
  24. data/lib/whop/dsl.rb +107 -0
  25. data/lib/whop/dsl_prelude.rb +23 -0
  26. data/lib/whop/error.rb +5 -0
  27. data/lib/whop/token.rb +37 -0
  28. data/lib/whop/version.rb +5 -0
  29. data/lib/whop/webhooks/engine.rb +17 -0
  30. data/lib/whop/webhooks/signature.rb +52 -0
  31. data/lib/whop.rb +51 -0
  32. metadata +206 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9645be1e4507621dd2c72c4703f31b5145a9b52ccf5b06069165b58a543730c5
4
+ data.tar.gz: 69670ad752cdb9b4135ff02dfae501e3adc01556aa3d24a0b625b36674bcdaa5
5
+ SHA512:
6
+ metadata.gz: 85298153f9224db542ed3b3aac8b1095c47b7992d7705614cdd71958dd0a6ecc9820593cfec65fed539933b5919cee0107f95e0a2eac6a1fe2293c30ad05e470
7
+ data.tar.gz: d41161106975b44aeb0d68fcea0ebb8b9bcc8277f0993bc953a5cb43831b8647f9e90e6f3b1b47cbdc39f3337dce634dd8a0bb001a84d551cf5e6d2e580fa418
data/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # whop-rails
2
+
3
+ Rails 7+ gem to build embedded Whop apps: token verification, access checks, API client, webhooks, and generators. Mirrors Whop's Next.js app template.
4
+
5
+ ## Install
6
+
7
+ Add to Gemfile:
8
+
9
+ ```ruby
10
+ gem "whop-rails", path: "."
11
+ ```
12
+
13
+ Generate initializer and mount webhooks:
14
+
15
+ ```bash
16
+ bin/rails g whop:install
17
+ ```
18
+
19
+ Set env vars:
20
+
21
+ - `WHOP_APP_ID`
22
+ - `WHOP_API_KEY`
23
+ - `WHOP_WEBHOOK_SECRET`
24
+ - (optional) `WHOP_AGENT_USER_ID`, `WHOP_COMPANY_ID`
25
+
26
+ ## Usage
27
+
28
+ ```ruby
29
+ class ExperiencesController < ApplicationController
30
+ include Whop::ControllerHelpers
31
+ before_action -> { require_whop_access!(experience_id: params[:id]) }
32
+
33
+ def show
34
+ user_id = whop_user_id
35
+ experience = Whop.client.experiences.get(params[:id])
36
+ render locals: { user_id:, experience: }
37
+ end
38
+ end
39
+ ```
40
+
41
+ Webhooks:
42
+
43
+ ```bash
44
+ bin/rails g whop:webhooks:handler payment_succeeded
45
+ # POST /whop/webhooks -> verifies signature, enqueues Whop::PaymentSucceededJob
46
+ ```
47
+
48
+ ## Example app template
49
+
50
+ ```bash
51
+ rails new whop_app -m examples/rails_app/template.rb --skip-jbuilder --skip-action-mailbox --skip-action-text --skip-active-storage
52
+ ```
53
+
54
+ ## License
55
+
56
+ MIT
57
+
58
+
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ Whop::Webhooks::Engine.routes.draw do
2
+ post "/", to: "webhooks#receive"
3
+ end
4
+
5
+
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails application template for a Whop-enabled embedded app.
4
+ # Usage:
5
+ # rails new whop_app -m examples/rails_app/template.rb --skip-jbuilder --skip-action-mailbox --skip-action-text --skip-active-storage
6
+
7
+ say "Adding whop-rails gem..."
8
+ append_to_file "Gemfile", <<~RUBY
9
+
10
+ gem "whop-rails", path: File.expand_path("../../", __dir__)
11
+ RUBY
12
+
13
+ run "bundle install"
14
+
15
+ say "Installing Whop initializer and webhooks engine..."
16
+ generate "whop:install"
17
+
18
+ env_keys = %w[WHOP_APP_ID WHOP_API_KEY WHOP_WEBHOOK_SECRET]
19
+ say "Remember to set ENV: #{env_keys.join(', ')}", :yellow
20
+
21
+ say "Adding ExperiencesController and route..."
22
+ create_file "app/controllers/experiences_controller.rb", <<~RUBY
23
+ class ExperiencesController < ApplicationController
24
+ include Whop::ControllerHelpers
25
+ before_action -> { require_whop_access!(experience_id: params[:id]) }
26
+
27
+ def show
28
+ user_id = whop_user_id
29
+ experience = Whop.client.experiences.get(params[:id])
30
+ render :show, locals: { user_id: user_id, experience: experience }
31
+ end
32
+ end
33
+ RUBY
34
+
35
+ create_file "app/views/experiences/show.html.erb", <<~ERB
36
+ <div class="container">
37
+ <h1>Experience</h1>
38
+ <p>User ID: <%= user_id %></p>
39
+ <pre><%= JSON.pretty_generate(experience) %></pre>
40
+ </div>
41
+ ERB
42
+
43
+ route "resources :experiences, only: [:show]"
44
+
45
+ say "Adding Discover page..."
46
+ create_file "app/controllers/discover_controller.rb", <<~RUBY
47
+ class DiscoverController < ApplicationController
48
+ def show; end
49
+ end
50
+ RUBY
51
+
52
+ create_file "app/views/discover/show.html.erb", <<~ERB
53
+ <div class="container">
54
+ <h1>Discover your app</h1>
55
+ <p>Showcase value, link to communities, add referral params.</p>
56
+ </div>
57
+ ERB
58
+
59
+ route "get '/discover', to: 'discover#show'"
60
+
61
+ say "Generating example webhook handler..."
62
+ generate "whop:webhooks:handler", "payment_succeeded"
63
+
64
+ say "All set. Configure ENV and run: bin/rails server"
65
+
66
+
@@ -0,0 +1,23 @@
1
+ require "rails/generators"
2
+
3
+ module Whop
4
+ module Generators
5
+ class DiscoverPageGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ def create_controller
9
+ template "discover_controller.rb", "app/controllers/discover_controller.rb"
10
+ end
11
+
12
+ def add_route
13
+ route "get '/discover', to: 'discover#show'"
14
+ end
15
+
16
+ def create_view
17
+ template "show.html.erb", "app/views/discover/show.html.erb"
18
+ end
19
+ end
20
+ end
21
+ end
22
+
23
+
@@ -0,0 +1,6 @@
1
+ class DiscoverController < ApplicationController
2
+ def show
3
+ end
4
+ end
5
+
6
+
@@ -0,0 +1,6 @@
1
+ <div class="container">
2
+ <h1>Discover your app</h1>
3
+ <p>Showcase value, link to communities, add referral params.</p>
4
+ </div>
5
+
6
+
@@ -0,0 +1,23 @@
1
+ require "rails/generators"
2
+
3
+ module Whop
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ def create_initializer
9
+ template "whop.rb", "config/initializers/whop.rb"
10
+ end
11
+
12
+ def mount_engine
13
+ route "mount Whop::Webhooks::Engine => '/whop/webhooks'"
14
+ end
15
+
16
+ def create_iframe_initializer
17
+ template "whop_iframe.rb", "config/initializers/whop_iframe.rb"
18
+ end
19
+ end
20
+ end
21
+ end
22
+
23
+
@@ -0,0 +1,10 @@
1
+ Whop.configure do |config|
2
+ config.app_id = ENV["WHOP_APP_ID"]
3
+ config.api_key = ENV["WHOP_API_KEY"]
4
+ config.webhook_secret = ENV["WHOP_WEBHOOK_SECRET"]
5
+ config.agent_user_id = ENV["WHOP_AGENT_USER_ID"]
6
+ config.company_id = ENV["WHOP_COMPANY_ID"]
7
+ # config.api_base_url = "https://api.whop.com"
8
+ end
9
+
10
+
@@ -0,0 +1,9 @@
1
+ # Allow Whop to embed this app in an iframe
2
+
3
+ Rails.application.config.action_dispatch.default_headers.delete('X-Frame-Options')
4
+
5
+ Rails.application.config.content_security_policy do |policy|
6
+ policy.frame_ancestors :self, "https://whop.com", "https://*.whop.com"
7
+ end
8
+
9
+
@@ -0,0 +1,22 @@
1
+ require "rails/generators"
2
+
3
+ module Whop
4
+ module Scaffold
5
+ module Generators
6
+ class AllGenerator < Rails::Generators::Base
7
+ argument :company_id, type: :string, required: false, default: nil, desc: "(optional) Whop Company ID"
8
+ argument :experience_id, type: :string, required: false, default: nil, desc: "(optional) Whop Experience ID"
9
+
10
+ def scaffold_company
11
+ invoke "whop:scaffold:company", [company_id].compact
12
+ end
13
+
14
+ def scaffold_experience
15
+ invoke "whop:scaffold:experience", [experience_id].compact
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+
@@ -0,0 +1,26 @@
1
+ require "rails/generators"
2
+
3
+ module Whop
4
+ module Scaffold
5
+ module Generators
6
+ class CompanyGenerator < Rails::Generators::Base
7
+ argument :company_id, type: :string, required: false, default: nil, desc: "(optional) Whop Company ID (not required)"
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ def create_controller
11
+ template "companies_controller.rb", "app/controllers/companies_controller.rb"
12
+ end
13
+
14
+ def add_route
15
+ route "get '/dashboard/:companyId', to: 'companies#show', as: :dashboard_company"
16
+ end
17
+
18
+ def create_view
19
+ template "show.html.erb", "app/views/companies/show.html.erb"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+
@@ -0,0 +1,17 @@
1
+ class CompaniesController < ApplicationController
2
+ include Whop::ControllerHelpers
3
+ before_action -> { require_whop_access!(company_id: params[:companyId] || params[:id]) }
4
+
5
+ def show
6
+ user_id = whop_user_id
7
+ company_id = params[:companyId] || params[:id]
8
+ company = begin
9
+ Whop.client.with_company(company_id).companies.get(company_id)
10
+ rescue StandardError
11
+ { "id" => company_id }
12
+ end
13
+ render :show, locals: { user_id:, company: }
14
+ end
15
+ end
16
+
17
+
@@ -0,0 +1,7 @@
1
+ <div class="container">
2
+ <h1>Company</h1>
3
+ <p>User ID: <%%= user_id %></p>
4
+ <pre><%%= JSON.pretty_generate(company) %></pre>
5
+ </div>
6
+
7
+
@@ -0,0 +1,26 @@
1
+ require "rails/generators"
2
+
3
+ module Whop
4
+ module Scaffold
5
+ module Generators
6
+ class ExperienceGenerator < Rails::Generators::Base
7
+ argument :experience_id, type: :string, required: false, default: nil, desc: "(optional) Whop Experience ID (not required)"
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ def create_controller
11
+ template "experiences_controller.rb", "app/controllers/experiences_controller.rb"
12
+ end
13
+
14
+ def add_route
15
+ route "get '/experiences/:experienceId', to: 'experiences#show', as: :experience"
16
+ end
17
+
18
+ def create_view
19
+ template "show.html.erb", "app/views/experiences/show.html.erb"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+
@@ -0,0 +1,17 @@
1
+ class ExperiencesController < ApplicationController
2
+ include Whop::ControllerHelpers
3
+ before_action -> { require_whop_access!(experience_id: params[:experienceId] || params[:id]) }
4
+
5
+ def show
6
+ user_id = whop_user_id
7
+ exp_id = params[:experienceId] || params[:id]
8
+ experience = begin
9
+ Whop.client.experiences.get(exp_id)
10
+ rescue StandardError
11
+ { "id" => exp_id }
12
+ end
13
+ render :show, locals: { user_id:, experience: }
14
+ end
15
+ end
16
+
17
+
@@ -0,0 +1,7 @@
1
+ <div class="container">
2
+ <h1>Experience</h1>
3
+ <p>User ID: <%%= user_id %></p>
4
+ <pre><%%= JSON.pretty_generate(experience) %></pre>
5
+ </div>
6
+
7
+
@@ -0,0 +1,17 @@
1
+ require "rails/generators"
2
+
3
+ module Whop
4
+ module Webhooks
5
+ module Generators
6
+ class HandlerGenerator < Rails::Generators::NamedBase
7
+ source_root File.expand_path("templates", __dir__)
8
+
9
+ def create_job
10
+ template "job.rb", File.join("app/jobs/whop", "#{file_name}_job.rb")
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+
17
+
@@ -0,0 +1,10 @@
1
+ class <%= class_name %>Job < ApplicationJob
2
+ queue_as :default
3
+
4
+ def perform(event)
5
+ # event is a Hash with keys: "action", "data", etc.
6
+ Rails.logger.info("Whop webhook <%= file_name %> received: #{event.inspect}")
7
+ end
8
+ end
9
+
10
+
@@ -0,0 +1,15 @@
1
+ require "rails/generators"
2
+
3
+ module Whop
4
+ module Webhooks
5
+ module Generators
6
+ class InstallGenerator < Rails::Generators::Base
7
+ def add_route
8
+ route "mount Whop::Webhooks::Engine => '/whop/webhooks'"
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
14
+
15
+
@@ -0,0 +1,37 @@
1
+ module Whop
2
+ # Access helpers using persisted GraphQL operations per Whop docs
3
+ class Access
4
+ def initialize(client)
5
+ @client = client
6
+ end
7
+
8
+ def user_has_access_to_experience?(user_id:, experience_id:)
9
+ data = @client.graphql("CheckIfUserHasAccessToExperience", { userId: user_id, experienceId: experience_id })
10
+ extract_access_boolean(data)
11
+ end
12
+
13
+ def user_has_access_to_access_pass?(user_id:, access_pass_id:)
14
+ data = @client.graphql("CheckIfUserHasAccessToAccessPass", { userId: user_id, accessPassId: access_pass_id })
15
+ extract_access_boolean(data)
16
+ end
17
+
18
+ def user_has_access_to_company?(user_id:, company_id:)
19
+ data = @client.graphql("CheckIfUserHasAccessToCompany", { userId: user_id, companyId: company_id })
20
+ extract_access_boolean(data)
21
+ end
22
+
23
+ private
24
+
25
+ def extract_access_boolean(graphql_result)
26
+ if graphql_result.is_a?(Hash)
27
+ data = graphql_result["data"] || graphql_result
28
+ key = %w[hasAccessToExperience hasAccessToAccessPass hasAccessToCompany].find { |k| data.key?(k) rescue false }
29
+ payload = key ? data[key] : data
30
+ return payload["hasAccess"] if payload.is_a?(Hash) && payload.key?("hasAccess")
31
+ end
32
+ !!graphql_result
33
+ end
34
+ end
35
+ end
36
+
37
+
@@ -0,0 +1,177 @@
1
+ require "faraday"
2
+ require "json"
3
+ require "faraday/retry"
4
+
5
+ module Whop
6
+ class Error < StandardError; end
7
+
8
+ # Thin HTTP client for Whop API + GraphQL with context headers.
9
+ class Client
10
+ attr_reader :config, :on_behalf_of_user_id, :company_id
11
+
12
+ def initialize(config, on_behalf_of_user_id: nil, company_id: nil)
13
+ @config = config
14
+ @on_behalf_of_user_id = on_behalf_of_user_id || config.agent_user_id
15
+ @company_id = company_id || config.company_id
16
+ end
17
+
18
+ def with_user(user_id)
19
+ self.class.new(config, on_behalf_of_user_id: user_id, company_id: @company_id)
20
+ end
21
+
22
+ def with_company(company_id)
23
+ self.class.new(config, on_behalf_of_user_id: @on_behalf_of_user_id, company_id: company_id)
24
+ end
25
+
26
+ # REST helpers
27
+ def get(path, params: nil)
28
+ response = connection.get(path) do |req|
29
+ req.params.update(params) if params
30
+ end
31
+ parse_response!(response)
32
+ end
33
+
34
+ def post(path, json: nil)
35
+ response = connection.post(path) do |req|
36
+ req.headers["Content-Type"] = "application/json"
37
+ req.body = JSON.generate(json) if json
38
+ end
39
+ parse_response!(response)
40
+ end
41
+
42
+ # GraphQL (persisted operations by operationName)
43
+ def graphql(operation_name, variables = {})
44
+ response = Faraday.post("#{config.api_base_url}/public-graphql") do |req|
45
+ apply_common_headers(req.headers)
46
+ req.headers["Content-Type"] = "application/json"
47
+ req.body = JSON.generate({ operationName: operation_name, variables: variables })
48
+ end
49
+ parse_response!(response)
50
+ end
51
+
52
+ # Resources
53
+ def users
54
+ @users ||= Resources::Users.new(self)
55
+ end
56
+
57
+ def experiences
58
+ @experiences ||= Resources::Experiences.new(self)
59
+ end
60
+
61
+ def companies
62
+ @companies ||= Resources::Companies.new(self)
63
+ end
64
+
65
+ def access
66
+ require_relative "access"
67
+ @access ||= Access.new(self)
68
+ end
69
+
70
+ private
71
+
72
+ def connection
73
+ @connection ||= Faraday.new(url: config.api_base_url) do |faraday|
74
+ faraday.request :retry, max: 2, interval: 0.1, backoff_factor: 2
75
+ faraday.response :raise_error
76
+ faraday.adapter Faraday.default_adapter
77
+ end
78
+ end
79
+
80
+ def apply_common_headers(headers)
81
+ headers["Authorization"] = "Bearer #{config.api_key}"
82
+ headers["x-on-behalf-of"] = on_behalf_of_user_id if on_behalf_of_user_id
83
+ headers["x-company-id"] = company_id if company_id
84
+ end
85
+
86
+ def parse_response!(response)
87
+ body = response.body
88
+ json = parse_body_safely(body)
89
+ if response.status.to_i >= 400
90
+ raise Error, "Whop API error (#{response.status}): #{json.inspect}"
91
+ end
92
+ json
93
+ end
94
+
95
+ def parse_body_safely(body)
96
+ return body unless body.is_a?(String) && !body.empty?
97
+ JSON.parse(body)
98
+ rescue JSON::ParserError
99
+ body
100
+ end
101
+ end
102
+ end
103
+
104
+ module Whop
105
+ module Resources
106
+ class Base
107
+ attr_reader :client
108
+ def initialize(client)
109
+ @client = client
110
+ end
111
+ end
112
+
113
+ class Users < Base
114
+ def get(user_id)
115
+ client.get("/v5/users/#{user_id}")
116
+ end
117
+ end
118
+
119
+ class Experiences < Base
120
+ def get(experience_id)
121
+ client.get("/v5/experiences/#{experience_id}")
122
+ end
123
+ end
124
+
125
+ class Companies < Base
126
+ def get(company_id)
127
+ # If the client is already scoped to this company, use the context-aware endpoint
128
+ if client.company_id && client.company_id == company_id
129
+ # Whop v5 exposes a context-aware company endpoint that reads x-company-id
130
+ return client.get("/v5/company")
131
+ end
132
+
133
+ # Otherwise, fetch via app-scoped companies endpoint by id
134
+ client.get("/v5/app/companies/#{company_id}")
135
+ end
136
+ end
137
+ end
138
+ end
139
+
140
+ module Whop
141
+ # Access helpers using persisted GraphQL operations per Whop docs
142
+ class Access
143
+ def initialize(client)
144
+ @client = client
145
+ end
146
+
147
+ def user_has_access_to_experience?(user_id:, experience_id:)
148
+ data = @client.graphql("CheckIfUserHasAccessToExperience", { userId: user_id, experienceId: experience_id })
149
+ extract_access_boolean(data)
150
+ end
151
+
152
+ def user_has_access_to_access_pass?(user_id:, access_pass_id:)
153
+ data = @client.graphql("CheckIfUserHasAccessToAccessPass", { userId: user_id, accessPassId: access_pass_id })
154
+ extract_access_boolean(data)
155
+ end
156
+
157
+ def user_has_access_to_company?(user_id:, company_id:)
158
+ data = @client.graphql("CheckIfUserHasAccessToCompany", { userId: user_id, companyId: company_id })
159
+ extract_access_boolean(data)
160
+ end
161
+
162
+ private
163
+
164
+ def extract_access_boolean(graphql_result)
165
+ # Attempt to locate the access payload; tolerate schema variants
166
+ if graphql_result.is_a?(Hash)
167
+ data = graphql_result["data"] || graphql_result
168
+ key = %w[hasAccessToExperience hasAccessToAccessPass hasAccessToCompany].find { |k| data.key?(k) rescue false }
169
+ payload = key ? data[key] : data
170
+ return payload["hasAccess"] if payload.is_a?(Hash) && payload.key?("hasAccess")
171
+ end
172
+ !!graphql_result
173
+ end
174
+ end
175
+ end
176
+
177
+
@@ -0,0 +1,51 @@
1
+ module Whop
2
+ module ControllerHelpers
3
+ private
4
+
5
+ def whop_user_id
6
+ # Primary: verified JWT from header
7
+ token = request.headers["x-whop-user-token"] || request.headers["X-Whop-User-Token"]
8
+ if token.present?
9
+ payload = Whop::Token.verify_from_jwt(token)
10
+ app_id = payload["aud"]
11
+ raise Whop::Error, "Invalid app audience" if app_id != (ENV["WHOP_APP_ID"] || Whop.config.app_id)
12
+ return payload["sub"]
13
+ end
14
+
15
+ # Development fallback: support whop-dev-user-token (header or param)
16
+ if defined?(Rails) && Rails.env.development?
17
+ dev_token = request.get_header("HTTP_WHOP_DEV_USER_TOKEN") || request.headers["whop-dev-user-token"] || params["whop-dev-user-token"] || params[:whop_dev_user_token]
18
+ if dev_token.present?
19
+ # If looks like JWT, try to verify; otherwise treat as direct user_id
20
+ if dev_token.include?(".")
21
+ payload = Whop::Token.verify_from_jwt(dev_token)
22
+ return payload["sub"]
23
+ else
24
+ return dev_token
25
+ end
26
+ end
27
+ end
28
+
29
+ nil
30
+ end
31
+
32
+ def require_whop_access!(experience_id: nil, access_pass_id: nil, company_id: nil)
33
+ uid = whop_user_id
34
+ raise Whop::Error, "Missing Whop user token" if uid.nil?
35
+
36
+ has_access = if experience_id
37
+ Whop.client.access.user_has_access_to_experience?(user_id: uid, experience_id: experience_id)
38
+ elsif access_pass_id
39
+ Whop.client.access.user_has_access_to_access_pass?(user_id: uid, access_pass_id: access_pass_id)
40
+ elsif company_id
41
+ Whop.client.access.user_has_access_to_company?(user_id: uid, company_id: company_id)
42
+ else
43
+ true
44
+ end
45
+
46
+ render plain: "Forbidden", status: :forbidden unless has_access
47
+ end
48
+ end
49
+ end
50
+
51
+
data/lib/whop/dsl.rb ADDED
@@ -0,0 +1,107 @@
1
+ module Whop
2
+ module DSL
3
+ class Registry
4
+ attr_reader :resources
5
+ def initialize
6
+ @resources = {}
7
+ end
8
+
9
+ def resource(name, &block)
10
+ ns = (@resources[name.to_sym] ||= Namespace.new(name))
11
+ ns.instance_eval(&block) if block
12
+ ns
13
+ end
14
+ end
15
+
16
+ class Namespace
17
+ attr_reader :name, :methods
18
+ def initialize(name)
19
+ @name = name.to_sym
20
+ @methods = {}
21
+ end
22
+
23
+ def graphql(method_name, operation:, args: [])
24
+ @methods[method_name.to_sym] = { type: :graphql, operation: operation, args: Array(args).map(&:to_sym) }
25
+ end
26
+
27
+ def rest_get(method_name, path:, args: [], params: [])
28
+ @methods[method_name.to_sym] = { type: :rest_get, path: path, args: Array(args).map(&:to_sym), params: Array(params).map(&:to_sym) }
29
+ end
30
+
31
+ def rest_post(method_name, path:, args: [], body: [])
32
+ @methods[method_name.to_sym] = { type: :rest_post, path: path, args: Array(args).map(&:to_sym), body: Array(body).map(&:to_sym) }
33
+ end
34
+ end
35
+
36
+ class ClientProxy
37
+ def initialize(client, registry)
38
+ @client = client
39
+ @registry = registry
40
+ end
41
+
42
+ def method_missing(name, *args, **kwargs, &block)
43
+ ns = @registry.resources[name.to_sym]
44
+ return super unless ns
45
+ NamespaceProxy.new(@client, ns)
46
+ end
47
+
48
+ def respond_to_missing?(name, include_all = false)
49
+ @registry.resources.key?(name.to_sym) || super
50
+ end
51
+ end
52
+
53
+ class NamespaceProxy
54
+ def initialize(client, namespace)
55
+ @client = client
56
+ @namespace = namespace
57
+ end
58
+
59
+ def method_missing(name, *args, **kwargs, &block)
60
+ spec = @namespace.methods[name.to_sym]
61
+ return super unless spec
62
+ case spec[:type]
63
+ when :graphql
64
+ variables = build_named_args(spec[:args], args, kwargs)
65
+ @client.graphql(spec[:operation], variables)
66
+ when :rest_get
67
+ path = interpolate_path(spec[:path], build_named_args(spec[:args], args, kwargs))
68
+ query = kwargs.select { |k, _| spec[:params].include?(k.to_sym) }
69
+ @client.get(path, params: query)
70
+ when :rest_post
71
+ path = interpolate_path(spec[:path], build_named_args(spec[:args], args, kwargs))
72
+ body = kwargs.select { |k, _| spec[:body].include?(k.to_sym) }
73
+ @client.post(path, json: body)
74
+ else
75
+ raise Whop::Error, "Unknown DSL method type: #{spec[:type]}"
76
+ end
77
+ end
78
+
79
+ def respond_to_missing?(name, include_all = false)
80
+ @namespace.methods.key?(name.to_sym) || super
81
+ end
82
+
83
+ private
84
+
85
+ def build_named_args(arg_names, args, kwargs)
86
+ return kwargs if kwargs && !kwargs.empty?
87
+ Hash[arg_names.zip(args)]
88
+ end
89
+
90
+ def interpolate_path(path, named)
91
+ path.gsub(/:(\w+)/) { |m| named[$1.to_sym] }
92
+ end
93
+ end
94
+
95
+ module_function
96
+
97
+ def registry
98
+ @registry ||= Registry.new
99
+ end
100
+
101
+ def define(&block)
102
+ registry.instance_eval(&block)
103
+ end
104
+ end
105
+ end
106
+
107
+
@@ -0,0 +1,23 @@
1
+ require_relative "dsl"
2
+
3
+ Whop::DSL.define do
4
+ resource :access do
5
+ graphql :check_if_user_has_access_to_experience, operation: "CheckIfUserHasAccessToExperience", args: %i[userId experienceId]
6
+ graphql :check_if_user_has_access_to_access_pass, operation: "CheckIfUserHasAccessToAccessPass", args: %i[userId accessPassId]
7
+ graphql :check_if_user_has_access_to_company, operation: "CheckIfUserHasAccessToCompany", args: %i[userId companyId]
8
+ end
9
+
10
+ resource :users do
11
+ rest_get :get, path: "/v5/users/:userId", args: %i[userId]
12
+ end
13
+
14
+ resource :experiences do
15
+ rest_get :get, path: "/v5/experiences/:experienceId", args: %i[experienceId]
16
+ end
17
+
18
+ resource :companies do
19
+ rest_get :get, path: "/v5/companies/:companyId", args: %i[companyId]
20
+ end
21
+ end
22
+
23
+
data/lib/whop/error.rb ADDED
@@ -0,0 +1,5 @@
1
+ module Whop
2
+ class Error < StandardError; end
3
+ end
4
+
5
+
data/lib/whop/token.rb ADDED
@@ -0,0 +1,37 @@
1
+ require "jwt"
2
+ require "openssl"
3
+
4
+ module Whop
5
+ module Token
6
+ JWT_PEM = <<~PEM.freeze
7
+ -----BEGIN PUBLIC KEY-----
8
+ MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAErz8a8vxvexHC0TLT91g7llOdDOsN
9
+ uYiGEfic4Qhni+HMfRBuUphOh7F3k8QgwZc9UlL0AHmyYqtbhL9NuJes6w==
10
+ -----END PUBLIC KEY-----
11
+ PEM
12
+
13
+ module_function
14
+
15
+ def verify(headers)
16
+ token = headers["x-whop-user-token"] || headers["X-Whop-User-Token"]
17
+ raise Whop::Error, "Missing x-whop-user-token header" if token.nil? || token.empty?
18
+ payload = verify_from_jwt(token)
19
+ app_id = payload["aud"]
20
+ expected = ENV["WHOP_APP_ID"] || Whop.config.app_id
21
+ raise Whop::Error, "Token audience mismatch" if expected && app_id != expected
22
+ { "user_id" => payload["sub"] }
23
+ end
24
+
25
+ def verify_from_jwt(token)
26
+ key = OpenSSL::PKey::EC.new(JWT_PEM)
27
+ payload, _header = JWT.decode(token, key, true, {
28
+ iss: "urn:whopcom:exp-proxy",
29
+ verify_iss: true,
30
+ algorithm: "ES256"
31
+ })
32
+ payload
33
+ end
34
+ end
35
+ end
36
+
37
+
@@ -0,0 +1,5 @@
1
+ module Whop
2
+ VERSION = "1.0.0"
3
+ end
4
+
5
+
@@ -0,0 +1,17 @@
1
+ require "rails/engine"
2
+
3
+ module Whop
4
+ module Webhooks
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace Whop::Webhooks
7
+
8
+ initializer "whop.webhooks.routes" do
9
+ Whop::Webhooks::Engine.routes.draw do
10
+ post "/", to: "webhooks#receive"
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+
17
+
@@ -0,0 +1,52 @@
1
+ require "openssl"
2
+
3
+ module Whop
4
+ module Webhooks
5
+ module Signature
6
+ module_function
7
+
8
+ # Compute hex HMAC-SHA256 digest of the given payload using the secret.
9
+ def compute(secret, payload)
10
+ OpenSSL::HMAC.hexdigest("SHA256", secret, payload.to_s)
11
+ end
12
+
13
+ # Compare provided signature header to computed digest in constant time.
14
+ # Accepts formats like "sha256=<hex>" or raw hex.
15
+ def valid?(secret, payload, provided)
16
+ return false if secret.to_s.empty? || payload.nil? || provided.to_s.empty?
17
+ given = provided.to_s
18
+ given = given.split("=", 2).last if given.include?("=")
19
+ expected_primary = compute(secret, payload)
20
+ return true if secure_compare(expected_primary, given)
21
+ # Fallback: tolerate JSON formatting differences (dev convenience)
22
+ normalized = normalize_json(payload)
23
+ if normalized
24
+ expected_canonical = compute(secret, normalized)
25
+ return true if secure_compare(expected_canonical, given)
26
+ end
27
+ false
28
+ end
29
+
30
+ def normalize_json(payload)
31
+ begin
32
+ obj = JSON.parse(payload)
33
+ JSON.generate(obj)
34
+ rescue StandardError
35
+ nil
36
+ end
37
+ end
38
+
39
+ # Constant-time comparison to avoid timing attacks.
40
+ def secure_compare(a, b)
41
+ return false unless a.bytesize == b.bytesize
42
+ l = a.unpack("C*")
43
+ r = b.unpack("C*")
44
+ result = 0
45
+ l.zip(r) { |x, y| result |= x ^ y }
46
+ result.zero?
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+
data/lib/whop.rb ADDED
@@ -0,0 +1,51 @@
1
+ require "active_support"
2
+ require "active_support/core_ext/module/attribute_accessors"
3
+
4
+ module Whop
5
+ # Base error type for gem
6
+ require_relative "whop/error"
7
+ class Configuration
8
+ attr_accessor :app_id, :api_key, :webhook_secret, :agent_user_id, :company_id, :api_base_url
9
+
10
+ def initialize
11
+ @api_base_url = "https://api.whop.com"
12
+ end
13
+ end
14
+
15
+ mattr_accessor :_config, instance_writer: false, default: Configuration.new
16
+
17
+ def self.configure
18
+ yield _config if block_given?
19
+ _config
20
+ end
21
+
22
+ def self.config
23
+ _config
24
+ end
25
+
26
+ def self.client
27
+ require_relative "whop/client"
28
+ @_client ||= Whop::Client.new(config)
29
+ end
30
+
31
+ def self.api
32
+ require_relative "whop/dsl"
33
+ DSL::ClientProxy.new(client, DSL.registry)
34
+ end
35
+ end
36
+
37
+ if defined?(Rails)
38
+ require_relative "whop/webhooks/engine"
39
+ end
40
+
41
+ # Load default DSL resource mappings
42
+ require_relative "whop/dsl_prelude"
43
+
44
+ # Ensure webhook signature verifier is loaded for controller usage
45
+ require_relative "whop/webhooks/signature"
46
+
47
+ # Load controller helpers so apps can include Whop::ControllerHelpers
48
+ require_relative "whop/token"
49
+ require_relative "whop/controller_helpers"
50
+
51
+
metadata ADDED
@@ -0,0 +1,206 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: whop
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Nikhil Nelson
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-10-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.9'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.9'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday-retry
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.2'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activesupport
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '7.0'
48
+ - - "<"
49
+ - !ruby/object:Gem::Version
50
+ version: '9.0'
51
+ type: :runtime
52
+ prerelease: false
53
+ version_requirements: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: '7.0'
58
+ - - "<"
59
+ - !ruby/object:Gem::Version
60
+ version: '9.0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: railties
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '7.0'
68
+ - - "<"
69
+ - !ruby/object:Gem::Version
70
+ version: '9.0'
71
+ type: :runtime
72
+ prerelease: false
73
+ version_requirements: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '7.0'
78
+ - - "<"
79
+ - !ruby/object:Gem::Version
80
+ version: '9.0'
81
+ - !ruby/object:Gem::Dependency
82
+ name: rack
83
+ requirement: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '2.2'
88
+ - - "<"
89
+ - !ruby/object:Gem::Version
90
+ version: '4.0'
91
+ type: :runtime
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '2.2'
98
+ - - "<"
99
+ - !ruby/object:Gem::Version
100
+ version: '4.0'
101
+ - !ruby/object:Gem::Dependency
102
+ name: json
103
+ requirement: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - "~>"
106
+ - !ruby/object:Gem::Version
107
+ version: '2.6'
108
+ type: :runtime
109
+ prerelease: false
110
+ version_requirements: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - "~>"
113
+ - !ruby/object:Gem::Version
114
+ version: '2.6'
115
+ - !ruby/object:Gem::Dependency
116
+ name: jwt
117
+ requirement: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - "~>"
120
+ - !ruby/object:Gem::Version
121
+ version: '2.8'
122
+ type: :runtime
123
+ prerelease: false
124
+ version_requirements: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - "~>"
127
+ - !ruby/object:Gem::Version
128
+ version: '2.8'
129
+ - !ruby/object:Gem::Dependency
130
+ name: rspec
131
+ requirement: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - "~>"
134
+ - !ruby/object:Gem::Version
135
+ version: '3.12'
136
+ type: :development
137
+ prerelease: false
138
+ version_requirements: !ruby/object:Gem::Requirement
139
+ requirements:
140
+ - - "~>"
141
+ - !ruby/object:Gem::Version
142
+ version: '3.12'
143
+ description: 'A Rails 7+ gem to build embedded Whop apps. Mirrors Whop''s Next.js
144
+ template: verification, access control, webhooks, and API client with a small meta-programming
145
+ DSL.'
146
+ email:
147
+ - thesolohacker47@gmail.com
148
+ executables: []
149
+ extensions: []
150
+ extra_rdoc_files: []
151
+ files:
152
+ - README.md
153
+ - config/routes.rb
154
+ - examples/rails_app/template.rb
155
+ - lib/generators/whop/discover_page/discover_page_generator.rb
156
+ - lib/generators/whop/discover_page/templates/discover_controller.rb
157
+ - lib/generators/whop/discover_page/templates/show.html.erb
158
+ - lib/generators/whop/install/install_generator.rb
159
+ - lib/generators/whop/install/templates/whop.rb
160
+ - lib/generators/whop/install/templates/whop_iframe.rb
161
+ - lib/generators/whop/scaffold/all/all_generator.rb
162
+ - lib/generators/whop/scaffold/company/company_generator.rb
163
+ - lib/generators/whop/scaffold/company/templates/companies_controller.rb
164
+ - lib/generators/whop/scaffold/company/templates/show.html.erb
165
+ - lib/generators/whop/scaffold/experience/experience_generator.rb
166
+ - lib/generators/whop/scaffold/experience/templates/experiences_controller.rb
167
+ - lib/generators/whop/scaffold/experience/templates/show.html.erb
168
+ - lib/generators/whop/webhooks/handler/handler_generator.rb
169
+ - lib/generators/whop/webhooks/handler/templates/job.rb
170
+ - lib/generators/whop/webhooks/install/install_generator.rb
171
+ - lib/whop.rb
172
+ - lib/whop/access.rb
173
+ - lib/whop/client.rb
174
+ - lib/whop/controller_helpers.rb
175
+ - lib/whop/dsl.rb
176
+ - lib/whop/dsl_prelude.rb
177
+ - lib/whop/error.rb
178
+ - lib/whop/token.rb
179
+ - lib/whop/version.rb
180
+ - lib/whop/webhooks/engine.rb
181
+ - lib/whop/webhooks/signature.rb
182
+ homepage: https://github.com/TheSoloHacker47/whop-gem
183
+ licenses:
184
+ - MIT
185
+ metadata: {}
186
+ post_install_message:
187
+ rdoc_options: []
188
+ require_paths:
189
+ - lib
190
+ required_ruby_version: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - ">="
193
+ - !ruby/object:Gem::Version
194
+ version: '3.2'
195
+ required_rubygems_version: !ruby/object:Gem::Requirement
196
+ requirements:
197
+ - - ">="
198
+ - !ruby/object:Gem::Version
199
+ version: '0'
200
+ requirements: []
201
+ rubygems_version: 3.5.3
202
+ signing_key:
203
+ specification_version: 4
204
+ summary: 'Rails integration for Whop Apps: config, token verification, access checks,
205
+ webhooks, generators.'
206
+ test_files: []