ruby_native 0.5.0 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2a9874ed5b3d89d70bbfdc1823dc9fdb9f21d069d9d7c2929f2d8a03fa4c7054
4
- data.tar.gz: ac20022d1ec9d8572303dc5e88797446363b7b7da0681aad49b5698ad49552b7
3
+ metadata.gz: d81a713c1634d9c87271bd6411bcc9b4056d872c80c41e202815e6e71e5d05b1
4
+ data.tar.gz: 758f9b00e03c0f1b4193a90ea81f16bf41dfab67c4a3998e381cc504e0a7278f
5
5
  SHA512:
6
- metadata.gz: '00951e78cd2b45e19baae4563e1bdbed1b292ddc2996ad4e698482dd5ea5fa322d596a1679411c2dadb7ea26d0a1e12a84f6bddab8dfb819f721a00bc8b126fd'
7
- data.tar.gz: 85f23e39ddfd0ebc4890d5a30ad02431855f4e8bf986e5fee7810ee362a357a0daec754e95bcbc926aef28f0e277dfb17c70729eb5b60da01beb167d9080b20a
6
+ metadata.gz: fed068320e56b295729ee0faab82405f37c2e23ba71475eebf0db6202ac415f030f050b8d63a154daabb25a0bf35574839c28a90738af74321fdc13596223f05
7
+ data.tar.gz: f2172a6767322cd9a2b61be6001db63ad7e2ca7aea7e640a70e8b2e27036d2a704dcf75b3e980305a7c7e3c45c38b24e978fcc8d7463bf46d252f80c4cf414a8
@@ -0,0 +1,67 @@
1
+ module RubyNative
2
+ module IAP
3
+ class CompletionsController < ::ActionController::Base
4
+ include Verifiable
5
+ include Decodable
6
+
7
+ skip_forgery_protection
8
+
9
+ def create
10
+ intent = PurchaseIntent.find_by!(uuid: params[:uuid])
11
+ return head :ok if intent.completed?
12
+
13
+ transaction = verify_transaction!(intent)
14
+
15
+ intent.update!(status: :completed, environment: transaction["environment"]&.downcase)
16
+
17
+ event = Event.new(
18
+ type: "subscription.created",
19
+ status: "active",
20
+ owner_token: intent.customer_id,
21
+ product_id: transaction["productId"],
22
+ original_transaction_id: transaction["originalTransactionId"],
23
+ transaction_id: transaction["transactionId"],
24
+ purchase_date: parse_timestamp(transaction["purchaseDate"]),
25
+ expires_date: parse_timestamp(transaction["expiresDate"]),
26
+ environment: transaction["environment"]&.downcase,
27
+ notification_uuid: SecureRandom.uuid,
28
+ success_path: intent.success_path
29
+ )
30
+
31
+ RubyNative.fire_subscription_callbacks(event)
32
+ head :ok
33
+ rescue ActiveRecord::RecordNotFound
34
+ head :not_found
35
+ rescue VerificationError, JWT::DecodeError => e
36
+ Rails.logger.warn "[RubyNative] Completion verification failed: #{e.message}"
37
+ head :unprocessable_entity
38
+ end
39
+
40
+ private
41
+
42
+ def verify_transaction!(intent)
43
+ signed_transaction = params[:signed_transaction]
44
+
45
+ if signed_transaction.present?
46
+ transaction = decode_and_verify_jws(signed_transaction)
47
+ if transaction["appAccountToken"] != intent.uuid
48
+ raise VerificationError, "appAccountToken does not match intent UUID"
49
+ end
50
+ transaction
51
+ elsif Rails.env.local?
52
+ # Allow unsigned completions for Xcode StoreKit testing in development.
53
+ {
54
+ "productId" => intent.product_id,
55
+ "originalTransactionId" => "xcode_#{intent.uuid}",
56
+ "transactionId" => "xcode_#{intent.uuid}",
57
+ "purchaseDate" => (Time.current.to_f * 1000).to_i,
58
+ "expiresDate" => (1.year.from_now.to_f * 1000).to_i,
59
+ "environment" => "Xcode"
60
+ }
61
+ else
62
+ raise VerificationError, "Missing signed_transaction"
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,18 @@
1
+ module RubyNative
2
+ module IAP
3
+ class PurchasesController < ::ActionController::Base
4
+ skip_forgery_protection
5
+
6
+ def create
7
+ intent = PurchaseIntent.create!(
8
+ customer_id: params[:customer_id],
9
+ product_id: params[:product_id],
10
+ success_path: params[:success_path],
11
+ environment: params[:environment] || "production"
12
+ )
13
+
14
+ render json: {uuid: intent.uuid, product_id: intent.product_id}
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,43 @@
1
+ module RubyNative
2
+ module IAP
3
+ class RestoresController < ::ActionController::Base
4
+ include Verifiable
5
+ include Decodable
6
+
7
+ skip_forgery_protection
8
+
9
+ def create
10
+ customer_id = params[:customer_id]
11
+ return head :bad_request if customer_id.blank?
12
+
13
+ transactions = Array(params[:signed_transactions])
14
+ return head :bad_request if transactions.empty?
15
+
16
+ transactions.each do |signed_transaction|
17
+ transaction = decode_and_verify_jws(signed_transaction)
18
+
19
+ event = Event.new(
20
+ type: "subscription.created",
21
+ status: "active",
22
+ owner_token: customer_id,
23
+ product_id: transaction["productId"],
24
+ original_transaction_id: transaction["originalTransactionId"],
25
+ transaction_id: transaction["transactionId"],
26
+ purchase_date: parse_timestamp(transaction["purchaseDate"]),
27
+ expires_date: parse_timestamp(transaction["expiresDate"]),
28
+ environment: transaction["environment"]&.downcase,
29
+ notification_uuid: SecureRandom.uuid,
30
+ success_path: params[:success_path]
31
+ )
32
+
33
+ RubyNative.fire_subscription_callbacks(event)
34
+ end
35
+
36
+ head :ok
37
+ rescue VerificationError, JWT::DecodeError => e
38
+ Rails.logger.warn "[RubyNative] Restore verification failed: #{e.message}"
39
+ head :unprocessable_entity
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,30 @@
1
+ module RubyNative
2
+ module Webhooks
3
+ class AppleController < ::ActionController::Base
4
+ skip_forgery_protection
5
+
6
+ def create
7
+ payload = JSON.parse(request.raw_post)
8
+ signed_payload = payload["signedPayload"]
9
+ return head :ok unless signed_payload
10
+
11
+ # TEST notifications from Apple have no transaction data to process.
12
+ decoded = JWT.decode(signed_payload, nil, false).first
13
+ return head :ok if decoded["notificationType"] == "TEST"
14
+
15
+ processor = RubyNative::IAP::AppleWebhookProcessor.new
16
+ processor.process(signed_payload)
17
+
18
+ head :ok
19
+ rescue JSON::ParserError
20
+ head :bad_request
21
+ rescue RubyNative::IAP::VerificationError, JWT::DecodeError => e
22
+ Rails.logger.error "[RubyNative] Apple webhook verification failed: #{e.message}"
23
+ head :unprocessable_entity
24
+ rescue => e
25
+ Rails.logger.error "[RubyNative] Apple webhook error: #{e.message}"
26
+ head :internal_server_error
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,20 @@
1
+ module RubyNative
2
+ module IAP
3
+ class PurchaseIntent < ::ActiveRecord::Base
4
+ self.table_name = "ruby_native_purchase_intents"
5
+
6
+ before_create :generate_uuid
7
+
8
+ enum :status, {pending: "pending", completed: "completed"}
9
+ enum :environment, {sandbox: "sandbox", production: "production", xcode: "xcode"}
10
+
11
+ validates :customer_id, presence: true
12
+
13
+ private
14
+
15
+ def generate_uuid
16
+ self.uuid ||= SecureRandom.uuid
17
+ end
18
+ end
19
+ end
20
+ end
data/config/routes.rb CHANGED
@@ -7,4 +7,12 @@ RubyNative::Engine.routes.draw do
7
7
  get "start/:provider", to: "start#show", as: :start
8
8
  resource :session, only: :show
9
9
  end
10
+ namespace :webhooks do
11
+ resource :apple, only: :create, controller: "apple"
12
+ end
13
+ namespace :iap do
14
+ resources :purchases, only: :create
15
+ post "completions/:uuid", to: "completions#create", as: :completion
16
+ resource :restore, only: :create
17
+ end
10
18
  end
@@ -0,0 +1,38 @@
1
+ require "rails/generators"
2
+ require "rails/generators/migration"
3
+
4
+ module RubyNative
5
+ module Generators
6
+ class IapGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ def self.next_migration_number(dirname)
12
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
13
+ end
14
+
15
+ def copy_migration
16
+ migration_template "create_ruby_native_purchase_intents.rb",
17
+ "db/migrate/create_ruby_native_purchase_intents.rb"
18
+ end
19
+
20
+ def print_next_steps
21
+ say ""
22
+ say "Ruby Native IAP installed!", :green
23
+ say ""
24
+ say " 1. Run migrations: bin/rails db:migrate"
25
+ say " 2. Add your callback in config/initializers/ruby_native.rb:"
26
+ say ""
27
+ say ' RubyNative.on_subscription_change do |event|'
28
+ say ' user = User.find_by(id: event.owner_token)'
29
+ say ' user&.update!(subscribed: event.active?)'
30
+ say ' end'
31
+ say ""
32
+ say " 3. Set your App Store Server Notification URL to:"
33
+ say " https://yourapp.com/native/webhooks/apple"
34
+ say ""
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,17 @@
1
+ class CreateRubyNativePurchaseIntents < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :ruby_native_purchase_intents do |t|
4
+ t.string :uuid, null: false
5
+ t.string :customer_id, null: false
6
+ t.string :product_id
7
+ t.string :success_path
8
+ t.string :status, null: false, default: "pending"
9
+ t.string :environment
10
+
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :ruby_native_purchase_intents, :uuid, unique: true
15
+ add_index :ruby_native_purchase_intents, :customer_id
16
+ end
17
+ end
@@ -2,6 +2,16 @@ module RubyNative
2
2
  class Engine < ::Rails::Engine
3
3
  isolate_namespace RubyNative
4
4
 
5
+ initializer "ruby_native.inflections", before: "ruby_native.helpers" do
6
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
7
+ inflect.acronym "IAP"
8
+ end
9
+ end
10
+
11
+ initializer "ruby_native.filter_params" do |app|
12
+ app.config.filter_parameters += [:signedPayload]
13
+ end
14
+
5
15
  initializer "ruby_native.helpers" do
6
16
  ActiveSupport.on_load(:action_controller_base) do
7
17
  include RubyNative::NativeDetection
@@ -0,0 +1,41 @@
1
+ module RubyNative
2
+ module IAP
3
+ class AppleWebhookProcessor
4
+ include Verifiable
5
+ include Decodable
6
+ include Normalizable
7
+
8
+ def process(signed_payload)
9
+ notification = parse_notification(signed_payload)
10
+ intent = PurchaseIntent.find_by(uuid: notification.app_account_token)
11
+
12
+ event = build_event(notification, intent)
13
+
14
+ intent&.update!(status: :completed) if intent&.pending?
15
+
16
+ RubyNative.fire_subscription_callbacks(event)
17
+ event
18
+ end
19
+
20
+ private
21
+
22
+ def build_event(notification, intent)
23
+ type = normalized_type(notification)
24
+
25
+ Event.new(
26
+ type: type,
27
+ status: STATUS_MAPPING[type] || "active",
28
+ owner_token: intent&.customer_id,
29
+ product_id: notification.product_id,
30
+ original_transaction_id: notification.original_transaction_id,
31
+ transaction_id: notification.transaction_id,
32
+ purchase_date: notification.purchase_date,
33
+ expires_date: notification.expires_date,
34
+ environment: notification.environment,
35
+ notification_uuid: notification.notification_uuid,
36
+ success_path: intent&.success_path
37
+ )
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,55 @@
1
+ require "jwt"
2
+
3
+ module RubyNative
4
+ module IAP
5
+ module Decodable
6
+ Notification = Data.define(
7
+ :notification_type,
8
+ :subtype,
9
+ :notification_uuid,
10
+ :bundle_id,
11
+ :app_account_token,
12
+ :product_id,
13
+ :original_transaction_id,
14
+ :transaction_id,
15
+ :purchase_date,
16
+ :expires_date,
17
+ :offer_type,
18
+ :environment
19
+ )
20
+
21
+ private
22
+
23
+ def parse_notification(signed_payload)
24
+ payload = decode_and_verify_jws(signed_payload)
25
+ transaction_info = decode_and_verify_jws(payload["data"]["signedTransactionInfo"])
26
+
27
+ Notification.new(
28
+ notification_type: payload["notificationType"],
29
+ subtype: payload["subtype"],
30
+ notification_uuid: payload["notificationUUID"],
31
+ bundle_id: payload["data"]["bundleId"],
32
+ app_account_token: transaction_info["appAccountToken"],
33
+ product_id: transaction_info["productId"],
34
+ original_transaction_id: transaction_info["originalTransactionId"],
35
+ transaction_id: transaction_info["transactionId"],
36
+ purchase_date: parse_timestamp(transaction_info["purchaseDate"]),
37
+ expires_date: parse_timestamp(transaction_info["expiresDate"]),
38
+ offer_type: transaction_info["offerType"],
39
+ environment: payload["data"]["environment"]&.downcase
40
+ )
41
+ end
42
+
43
+ def decode_and_verify_jws(jws)
44
+ JWT.decode(jws, nil, true, algorithm: "ES256") { |header|
45
+ verify_certificate_chain(header["x5c"])
46
+ }.first
47
+ end
48
+
49
+ def parse_timestamp(milliseconds)
50
+ return nil if milliseconds.nil?
51
+ Time.at(milliseconds / 1000.0).utc
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,42 @@
1
+ module RubyNative
2
+ module IAP
3
+ class Event
4
+ attr_reader :type, :status, :owner_token, :product_id,
5
+ :original_transaction_id, :transaction_id,
6
+ :purchase_date, :expires_date, :environment,
7
+ :notification_uuid, :success_path
8
+
9
+ def initialize(type:, status:, owner_token:, product_id:, original_transaction_id:,
10
+ transaction_id:, purchase_date:, expires_date:, environment:,
11
+ notification_uuid:, success_path:)
12
+ @type = type
13
+ @status = status
14
+ @owner_token = owner_token
15
+ @product_id = product_id
16
+ @original_transaction_id = original_transaction_id
17
+ @transaction_id = transaction_id
18
+ @purchase_date = purchase_date
19
+ @expires_date = expires_date
20
+ @environment = environment
21
+ @notification_uuid = notification_uuid
22
+ @success_path = success_path
23
+ end
24
+
25
+ def active?
26
+ status == "active"
27
+ end
28
+
29
+ def expired?
30
+ status == "expired"
31
+ end
32
+
33
+ def created?
34
+ type == "subscription.created"
35
+ end
36
+
37
+ def canceled?
38
+ type == "subscription.canceled"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,41 @@
1
+ module RubyNative
2
+ module IAP
3
+ module Normalizable
4
+ TYPE_MAPPING = {
5
+ "SUBSCRIBED" => "subscription.created",
6
+ "DID_RENEW" => "subscription.updated",
7
+ "DID_CHANGE_RENEWAL_STATUS" => {
8
+ "AUTO_RENEW_DISABLED" => "subscription.canceled",
9
+ "AUTO_RENEW_ENABLED" => "subscription.updated"
10
+ },
11
+ "DID_CHANGE_RENEWAL_INFO" => {
12
+ "UPGRADE" => "subscription.updated",
13
+ "DOWNGRADE" => "subscription.updated"
14
+ },
15
+ "EXPIRED" => "subscription.expired",
16
+ "DID_FAIL_TO_RENEW" => "subscription.updated",
17
+ "GRACE_PERIOD_EXPIRED" => "subscription.expired",
18
+ "REFUND" => "subscription.expired"
19
+ }.freeze
20
+
21
+ STATUS_MAPPING = {
22
+ "subscription.created" => "active",
23
+ "subscription.updated" => "active",
24
+ "subscription.canceled" => "active",
25
+ "subscription.expired" => "expired"
26
+ }.freeze
27
+
28
+ private
29
+
30
+ def normalized_type(notification)
31
+ mapping = TYPE_MAPPING[notification.notification_type]
32
+
33
+ if mapping.is_a?(Hash)
34
+ mapping[notification.subtype] || "subscription.updated"
35
+ else
36
+ mapping || "subscription.updated"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,37 @@
1
+ module RubyNative
2
+ module IAP
3
+ class VerificationError < StandardError; end
4
+
5
+ module Verifiable
6
+ APPLE_ROOT_CERT_PATH = File.join(__dir__, "..", "certs", "AppleRootCA-G3.cer")
7
+
8
+ private
9
+
10
+ def verify_certificate_chain(x5c_certs)
11
+ raise VerificationError, "Missing x5c certificates" if x5c_certs.nil? || x5c_certs.empty?
12
+
13
+ certs = x5c_certs.map do |cert_base64|
14
+ OpenSSL::X509::Certificate.new(Base64.decode64(cert_base64))
15
+ end
16
+
17
+ unless certs.last.to_der == apple_root_certificate.to_der
18
+ raise VerificationError, "Root certificate does not match Apple's known root"
19
+ end
20
+
21
+ certs.each_cons(2) do |child, parent|
22
+ unless child.verify(parent.public_key)
23
+ raise VerificationError, "Certificate chain verification failed"
24
+ end
25
+ end
26
+
27
+ certs.first.public_key
28
+ end
29
+
30
+ def apple_root_certificate
31
+ @apple_root_certificate ||= OpenSSL::X509::Certificate.new(
32
+ File.read(APPLE_ROOT_CERT_PATH)
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,3 +1,3 @@
1
1
  module RubyNative
2
- VERSION = "0.5.0"
2
+ VERSION = "0.5.3"
3
3
  end
data/lib/ruby_native.rb CHANGED
@@ -5,10 +5,24 @@ require "ruby_native/native_detection"
5
5
  require "ruby_native/inertia_support"
6
6
  require "ruby_native/oauth_middleware"
7
7
  require "ruby_native/tunnel_cookie_middleware"
8
+ require "ruby_native/iap/event"
9
+ require "ruby_native/iap/verifiable"
10
+ require "ruby_native/iap/decodable"
11
+ require "ruby_native/iap/normalizable"
12
+ require "ruby_native/iap/apple_webhook_processor"
8
13
  require "ruby_native/engine"
9
14
 
10
15
  module RubyNative
11
16
  mattr_accessor :config
17
+ mattr_accessor :subscription_callbacks, default: []
18
+
19
+ def self.on_subscription_change(&block)
20
+ subscription_callbacks << block
21
+ end
22
+
23
+ def self.fire_subscription_callbacks(event)
24
+ subscription_callbacks.each { |cb| cb.call(event) }
25
+ end
12
26
 
13
27
  def self.load_config
14
28
  path = Rails.root.join("config", "ruby_native.yml")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_native
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joe Masilotti
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '3.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: jwt
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.0'
40
54
  description: Turn your existing Rails app into a native iOS and Android app with Ruby
41
55
  Native.
42
56
  email:
@@ -52,7 +66,11 @@ files:
52
66
  - app/controllers/ruby_native/auth/sessions_controller.rb
53
67
  - app/controllers/ruby_native/auth/start_controller.rb
54
68
  - app/controllers/ruby_native/config_controller.rb
69
+ - app/controllers/ruby_native/iap/completions_controller.rb
70
+ - app/controllers/ruby_native/iap/purchases_controller.rb
71
+ - app/controllers/ruby_native/iap/restores_controller.rb
55
72
  - app/controllers/ruby_native/push/devices_controller.rb
73
+ - app/controllers/ruby_native/webhooks/apple_controller.rb
56
74
  - app/javascript/ruby_native/back.js
57
75
  - app/javascript/ruby_native/bridge/badge_controller.js
58
76
  - app/javascript/ruby_native/bridge/button_controller.js
@@ -65,14 +83,18 @@ files:
65
83
  - app/javascript/ruby_native/bridge/tabs_controller.js
66
84
  - app/javascript/ruby_native/react.js
67
85
  - app/javascript/ruby_native/vue.js
86
+ - app/models/ruby_native/iap/purchase_intent.rb
68
87
  - app/views/ruby_native/auth/start/show.html.erb
69
88
  - config/importmap.rb
70
89
  - config/routes.rb
71
90
  - exe/ruby_native
91
+ - lib/generators/ruby_native/iap_generator.rb
72
92
  - lib/generators/ruby_native/install_generator.rb
73
93
  - lib/generators/ruby_native/templates/CLAUDE.md
94
+ - lib/generators/ruby_native/templates/create_ruby_native_purchase_intents.rb
74
95
  - lib/generators/ruby_native/templates/ruby_native.yml
75
96
  - lib/ruby_native.rb
97
+ - lib/ruby_native/certs/AppleRootCA-G3.cer
76
98
  - lib/ruby_native/cli.rb
77
99
  - lib/ruby_native/cli/credentials.rb
78
100
  - lib/ruby_native/cli/deploy.rb
@@ -81,6 +103,11 @@ files:
81
103
  - lib/ruby_native/cli/screenshots.rb
82
104
  - lib/ruby_native/engine.rb
83
105
  - lib/ruby_native/helper.rb
106
+ - lib/ruby_native/iap/apple_webhook_processor.rb
107
+ - lib/ruby_native/iap/decodable.rb
108
+ - lib/ruby_native/iap/event.rb
109
+ - lib/ruby_native/iap/normalizable.rb
110
+ - lib/ruby_native/iap/verifiable.rb
84
111
  - lib/ruby_native/inertia_support.rb
85
112
  - lib/ruby_native/native_detection.rb
86
113
  - lib/ruby_native/native_version.rb