waffo-pancake 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c5283cad4a209984c83a1416a5b8a56ba039935e1d7c4ed3879349f5eeb6e5f7
4
+ data.tar.gz: 76a97790c5458a0ce01f9edc5c88f54bada6b04d29abd2904f768b35b06d4213
5
+ SHA512:
6
+ metadata.gz: 9f96eae8643cd2f8bc76c8ad36c4b48f991b0df654a1c696bf5cb53c5bbe0a48f9fa291809e3a3370aed504d3b7aeeda34b959ad91312cc62f1d21c0c0c95569
7
+ data.tar.gz: 21cc4f957a948cce53f880578094f8fece93ce4cc58f73500d1d82b0f8f3ef62d41f3ac012d4df92a42e017b99e53c85a269475445277b397938376714bebc4c
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - `Waffo::Pancake::Client`: signed merchant API requests (RSA-SHA256, `X-Merchant-Id` /
6
+ `X-Timestamp` / `X-Signature`), checkout sessions (anonymous and authenticated), subscription
7
+ cancel and reactivate, one-time order cancel, subscription and one-time products, webhook
8
+ endpoints, GraphQL and `subscription_order`.
9
+ - `Waffo::Pancake::Webhook.verify`: `X-Waffo-Signature` verification with the built-in test and
10
+ prod platform keys, environment-variable overrides and replay tolerance.
11
+ - Private and public keys accepted as PEM, PEM with literal `\n`, or bare base64.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Good First Issue
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # waffo-pancake
2
+
3
+ Ruby client for the [Waffo Pancake](https://docs.waffo.ai) merchant API. It signs requests with
4
+ your API key, creates checkout sessions, manages subscriptions and products, runs GraphQL
5
+ queries, and verifies webhook signatures. It is a Ruby port of the official TypeScript SDK,
6
+ [`@waffo/pancake-ts`](https://www.npmjs.com/package/@waffo/pancake-ts), built on the standard
7
+ library (`net/http`, `openssl`, `json`) plus the `base64` gem.
8
+
9
+ ## Install
10
+
11
+ ```ruby
12
+ gem "waffo-pancake"
13
+ ```
14
+
15
+ ## Configure
16
+
17
+ Create an API key in the Dashboard (**API & Development**). A key is bound to test or prod
18
+ when it is created, so the key decides the environment.
19
+
20
+ ```ruby
21
+ require "waffo/pancake"
22
+
23
+ client = Waffo::Pancake::Client.new(
24
+ merchant_id: ENV["WAFFO_MERCHANT_ID"], # MER_...
25
+ private_key: ENV["WAFFO_PRIVATE_KEY"], # PEM, PEM with literal \n, or bare base64
26
+ logger: Rails.logger # optional: one line per request, never the body
27
+ )
28
+ ```
29
+
30
+ Both arguments default to those environment variables. Keep the private key on the server.
31
+
32
+ ## Checkout
33
+
34
+ ```ruby
35
+ # Authenticated checkout (recommended): buyer_identity is your stable id for the customer.
36
+ # Waffo uses it for trial eligibility and the customer portal.
37
+ session = client.create_authenticated_checkout(
38
+ product_id: "PROD_...",
39
+ currency: "USD",
40
+ buyer_identity: "user-42",
41
+ buyer_email: "ada@example.com",
42
+ success_url: "https://example.com/settings?checkout=success",
43
+ order_merchant_external_id: "user-42", # comes back on every webhook for the order
44
+ metadata: { "user_id" => "42" }
45
+ )
46
+ redirect_to session["checkoutUrl"], allow_other_host: true # ends in #token=...
47
+
48
+ # Anonymous checkout: the buyer fills everything in on the page.
49
+ client.create_checkout_session(product_id: "PROD_...", currency: "USD")
50
+ ```
51
+
52
+ Top-level keyword arguments may be snake_case and are sent as camelCase. Nested hashes
53
+ (`metadata`, `prices`, `billing_detail`'s fields) are sent exactly as given.
54
+
55
+ ## Subscriptions
56
+
57
+ ```ruby
58
+ client.cancel_subscription("ORD_...") # => {"orderId" => "ORD_...", "status" => "canceling"}
59
+ client.reactivate_subscription("ORD_...") # while still canceling
60
+ client.subscription_order("ORD_...") # GraphQL; nil when unknown
61
+ # => {"status" => "active", "currentPeriodEnd" => "2027-10-08T...", "merchantProvidedBuyerIdentity" => "user-42", ...}
62
+ ```
63
+
64
+ `canceling` keeps access until `currentPeriodEnd`. The terminal `canceled` arrives as a webhook.
65
+
66
+ ## Products
67
+
68
+ ```ruby
69
+ client.create_subscription_product(
70
+ store_id: "STO_...", name: "Pro", billing_period: "yearly",
71
+ prices: { "USD" => { amount: "9.99", taxCategory: "saas" } }
72
+ )
73
+ client.publish_subscription_product(id: "PROD_...") # test → prod, first publish only
74
+ ```
75
+
76
+ Amounts are display strings (`"9.99"`), not cents.
77
+
78
+ ## GraphQL and other endpoints
79
+
80
+ ```ruby
81
+ client.graphql("query ($id: String!) { onetimeOrder(id: $id) { id status } }", id: "ORD_...")
82
+ client.action("/v1/actions/refund-ticket/create-ticket", { payment_id: "PAY_...", reason: "..." })
83
+ ```
84
+
85
+ ## Webhooks
86
+
87
+ Pass the raw body; a body that was parsed and re-serialized will not verify.
88
+
89
+ ```ruby
90
+ class WaffoWebhooksController < ActionController::Base
91
+ skip_forgery_protection
92
+
93
+ def create
94
+ event = Waffo::Pancake::Webhook.verify(
95
+ request.raw_post,
96
+ request.headers["X-Waffo-Signature"],
97
+ environment: "prod" # or "test"; omit to try prod, then test
98
+ )
99
+ # Deduplicate on event["id"]: a retry redelivers the same id.
100
+ case event["eventType"]
101
+ when "subscription.activated", "subscription.renewed" then # grant access until data.currentPeriodEnd
102
+ when "subscription.canceled" then # revoke access
103
+ end
104
+ head :ok
105
+ rescue Waffo::Pancake::InvalidSignature
106
+ head :unauthorized
107
+ end
108
+ end
109
+ ```
110
+
111
+ The platform's public keys are built in. When Waffo rotates them, set
112
+ `WAFFO_WEBHOOK_PROD_PUBLIC_KEY` / `WAFFO_WEBHOOK_TEST_PUBLIC_KEY` (or `WAFFO_WEBHOOK_PUBLIC_KEY` for
113
+ both), or pass `public_key:`. Timestamps may be up to 45 minutes old and 1 minute ahead by default
114
+ (`tolerance:`, `future_tolerance:`, in seconds; `tolerance: 0` turns the check off).
115
+
116
+ `Waffo::Pancake::Webhook::EVENT_TYPES` lists the event types.
117
+
118
+ ## Errors
119
+
120
+ | Class | When |
121
+ |---|---|
122
+ | `Waffo::Pancake::Rejected` | Waffo refused the request (4xx other than 429). `status` and `errors` carry the details. |
123
+ | `Waffo::Pancake::Unavailable` | Network failure, timeout, 429 or 5xx. Worth retrying later. |
124
+ | `Waffo::Pancake::ConfigurationError` | Missing merchant id, or a key that does not parse. |
125
+ | `Waffo::Pancake::InvalidSignature` | A webhook that does not verify. |
126
+
127
+ All inherit from `Waffo::Pancake::Error`.
128
+
129
+ ## Testing your code
130
+
131
+ `Client.new` takes a `transport:` callable that replaces the HTTP call. It receives the URI,
132
+ the headers and the JSON body and returns `[status, body]`.
133
+
134
+ ```ruby
135
+ transport = ->(uri, headers, json) { [200, '{"data":{"orderId":"ORD_1","status":"canceling"}}'] }
136
+ client = Waffo::Pancake::Client.new(merchant_id: "MER_x", private_key: key, transport: transport)
137
+ ```
138
+
139
+ Test cards: `4576 7500 0000 0110` succeeds and `4576 7500 0000 0220` is declined.
140
+
141
+ ## Development
142
+
143
+ ```sh
144
+ bundle install
145
+ bundle exec rake test
146
+ ```
147
+
148
+ Releases are published to RubyGems by the `Release` workflow when a `v*` tag is pushed. It uses
149
+ [trusted publishing](https://guides.rubygems.org/trusted-publishing/), so no API key is stored.
150
+
151
+ ## License
152
+
153
+ MIT
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "net/http"
6
+ require "openssl"
7
+ require "uri"
8
+
9
+ module Waffo
10
+ module Pancake
11
+ # The merchant API, authenticated with an API key: every request is a POST signed with the
12
+ # key's RSA private key. An API key is bound to test or prod when it is created, so no
13
+ # environment header is sent.
14
+ #
15
+ # client = Waffo::Pancake::Client.new(merchant_id: "MER_...", private_key: ENV["WAFFO_PRIVATE_KEY"])
16
+ # client.cancel_subscription("ORD_...") # => {"orderId" => "ORD_...", "status" => "canceling"}
17
+ #
18
+ # Parameters may be given in snake_case; top-level keys are sent in camelCase. Nested
19
+ # hashes (metadata, prices, billing details) are sent exactly as given.
20
+ class Client
21
+ DEFAULT_BASE_URL = "https://api.waffo.ai"
22
+ DEFAULT_TIMEOUT = 15
23
+
24
+ NETWORK_ERRORS = [
25
+ Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse
26
+ ].freeze
27
+
28
+ SUBSCRIPTION_ORDER_QUERY = <<~GRAPHQL
29
+ query ($id: String!) {
30
+ subscriptionOrder(id: $id) {
31
+ id storeId status testMode buyerEmail merchantProvidedBuyerIdentity orderMerchantExternalId
32
+ billingPeriod currentPeriodStart currentPeriodEnd canceledAt
33
+ }
34
+ }
35
+ GRAPHQL
36
+
37
+ attr_reader :merchant_id, :base_url
38
+
39
+ # The canonical request the gateway checks `X-Signature` against: method, path,
40
+ # timestamp (seconds) and the base64 SHA-256 of the body, one per line.
41
+ def self.sign(method:, path:, timestamp:, body:, private_key:)
42
+ body_hash = Base64.strict_encode64(OpenSSL::Digest::SHA256.digest(body))
43
+ canonical = "#{method}\n#{path}\n#{timestamp}\n#{body_hash}"
44
+ Base64.strict_encode64(private_key.sign(OpenSSL::Digest::SHA256.new, canonical))
45
+ end
46
+
47
+ # `transport` replaces the HTTP call: it takes the URI, the headers and the JSON body
48
+ # and answers `[status, body]` (status nil when there was no answer). `logger` gets one
49
+ # info line per request; neither the body nor the key is ever logged.
50
+ def initialize(merchant_id: ENV["WAFFO_MERCHANT_ID"], private_key: ENV["WAFFO_PRIVATE_KEY"],
51
+ base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT, logger: nil, transport: nil)
52
+ raise ConfigurationError, "Missing merchant_id (WAFFO_MERCHANT_ID)" if blank?(merchant_id)
53
+ raise ConfigurationError, "Missing private_key (WAFFO_PRIVATE_KEY)" if blank?(private_key)
54
+
55
+ @merchant_id = merchant_id
56
+ @private_key = Keys.private_key(private_key)
57
+ @base_url = base_url.to_s.chomp("/")
58
+ @timeout = timeout
59
+ @logger = logger
60
+ @transport = transport || method(:http_post)
61
+ end
62
+
63
+ # --- Checkout ----------------------------------------------------------------------
64
+
65
+ def issue_session_token(buyer_identity:, store_id: nil, product_id: nil)
66
+ raise ArgumentError, "store_id or product_id is required" if store_id.nil? && product_id.nil?
67
+
68
+ action("/v1/actions/auth/issue-session-token",
69
+ { buyer_identity: buyer_identity, store_id: store_id, product_id: product_id })
70
+ end
71
+
72
+ # Anonymous checkout: the buyer fills everything in on the page.
73
+ # Returns {"sessionId", "checkoutUrl", "expiresAt"}.
74
+ def create_checkout_session(product_id:, currency:, **params)
75
+ action("/v1/actions/checkout/create-session", { product_id: product_id, currency: currency, **params })
76
+ end
77
+
78
+ # Authenticated checkout: `buyer_identity` (your stable id for the customer) goes into a
79
+ # session token, which Waffo uses for trial eligibility and the customer portal. The
80
+ # token is appended to the checkout URL as a fragment (`#token=...`).
81
+ def create_authenticated_checkout(product_id:, currency:, buyer_identity:, **params)
82
+ token = issue_session_token(buyer_identity: buyer_identity, product_id: product_id)
83
+ session = create_checkout_session(product_id: product_id, currency: currency, **params)
84
+ session.merge(
85
+ "checkoutUrl" => "#{session.fetch("checkoutUrl")}#token=#{token.fetch("token")}",
86
+ "token" => token["token"],
87
+ "tokenExpiresAt" => token["expiresAt"]
88
+ )
89
+ end
90
+
91
+ # --- Orders ------------------------------------------------------------------------
92
+
93
+ # pending → canceled at once; active, trialing and past_due → canceling, ending with the
94
+ # paid period. The terminal `canceled` arrives later as a webhook.
95
+ def cancel_subscription(order_id)
96
+ action("/v1/actions/subscription-order/cancel-order", { order_id: order_id })
97
+ end
98
+
99
+ # Undoes a cancellation while the order is still `canceling`.
100
+ def reactivate_subscription(order_id)
101
+ action("/v1/actions/subscription-order/reactivate-order", { order_id: order_id })
102
+ end
103
+
104
+ def cancel_onetime_order(order_id)
105
+ action("/v1/actions/onetime-order/cancel-order", { order_id: order_id })
106
+ end
107
+
108
+ # Nil when Waffo knows no such order.
109
+ def subscription_order(order_id)
110
+ graphql(SUBSCRIPTION_ORDER_QUERY, id: order_id)&.dig("subscriptionOrder")
111
+ end
112
+
113
+ # --- Products ----------------------------------------------------------------------
114
+
115
+ # prices: { "USD" => { amount: "9.99", taxCategory: "saas" } }
116
+ def create_subscription_product(store_id:, name:, billing_period:, prices:, **params)
117
+ action("/v1/actions/subscription-product/create-product",
118
+ { store_id: store_id, name: name, billing_period: billing_period, prices: prices, **params })
119
+ end
120
+
121
+ def update_subscription_product(id:, **params)
122
+ action("/v1/actions/subscription-product/update-product", { id: id, **params })
123
+ end
124
+
125
+ # Copies the test version to production (first publish only).
126
+ def publish_subscription_product(id:)
127
+ action("/v1/actions/subscription-product/publish-product", { id: id })
128
+ end
129
+
130
+ def create_onetime_product(store_id:, name:, prices:, **params)
131
+ action("/v1/actions/onetime-product/create-product", { store_id: store_id, name: name, prices: prices, **params })
132
+ end
133
+
134
+ # --- Webhook endpoints -------------------------------------------------------------
135
+
136
+ def add_webhook(store_id:, url:, events:, test_mode:, channel: "http", **params)
137
+ action("/v1/actions/store/add-webhook",
138
+ { store_id: store_id, channel: channel, url: url, events: events, test_mode: test_mode, **params })
139
+ end
140
+
141
+ def remove_webhook(id:)
142
+ action("/v1/actions/store/remove-webhook", { id: id })
143
+ end
144
+
145
+ # --- Low level ---------------------------------------------------------------------
146
+
147
+ # Returns `data`. Raises when the response carries `errors` and no data.
148
+ def graphql(query, variables = {})
149
+ status, envelope = request("/v1/graphql", { "query" => query, "variables" => variables })
150
+ raise_for(status, envelope, "/v1/graphql") if present?(envelope["errors"]) && envelope["data"].nil?
151
+
152
+ envelope["data"]
153
+ end
154
+
155
+ # Any action endpoint. Returns the envelope's `data`; raises Rejected or Unavailable on
156
+ # `errors` or a non-2xx status.
157
+ def action(path, params)
158
+ status, envelope = request(path, camelize(params))
159
+ raise_for(status, envelope, path) if present?(envelope["errors"]) || !(200..299).cover?(status)
160
+
161
+ envelope["data"] || {}
162
+ end
163
+
164
+ private
165
+ def request(path, params)
166
+ json = JSON.generate(params)
167
+ timestamp = Time.now.to_i.to_s
168
+ headers = {
169
+ "Content-Type" => "application/json",
170
+ "X-Merchant-Id" => @merchant_id,
171
+ "X-Timestamp" => timestamp,
172
+ "X-Signature" => self.class.sign(method: "POST", path: path, timestamp: timestamp, body: json,
173
+ private_key: @private_key)
174
+ }
175
+
176
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
177
+ status, body = call_transport(URI("#{@base_url}#{path}"), headers, json)
178
+ elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(2)
179
+ @logger&.info("[Waffo] POST #{path} status=#{status.inspect} in #{elapsed}s")
180
+
181
+ raise Unavailable.new("Waffo #{path} unreachable: #{body}", status: nil) if status.nil?
182
+
183
+ envelope = parse(body)
184
+ raise_for(status, {}, path, "non-JSON response") unless envelope.is_a?(Hash)
185
+
186
+ [status, envelope]
187
+ end
188
+
189
+ def call_transport(uri, headers, json)
190
+ @transport.call(uri, headers, json)
191
+ rescue *NETWORK_ERRORS => e
192
+ [nil, "#{e.class}: #{e.message}"]
193
+ end
194
+
195
+ def http_post(uri, headers, json)
196
+ request = Net::HTTP::Post.new(uri, headers)
197
+ request.body = json
198
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout,
199
+ read_timeout: @timeout, write_timeout: @timeout) do |http|
200
+ http.request(request)
201
+ end
202
+ [response.code.to_i, response.body]
203
+ end
204
+
205
+ def raise_for(status, envelope, path, fallback = "request failed")
206
+ errors = Array(envelope["errors"])
207
+ message = errors.map { |e| e.is_a?(Hash) ? e["message"] : e.to_s }.compact.first || fallback
208
+ error = status.nil? || status == 429 || status >= 500 ? Unavailable : Rejected
209
+ raise error.new("Waffo #{path} status=#{status}: #{message}", status: status, errors: errors)
210
+ end
211
+
212
+ def parse(body)
213
+ JSON.parse(body.to_s)
214
+ rescue JSON::ParserError
215
+ nil
216
+ end
217
+
218
+ def camelize(params)
219
+ params.each_with_object({}) do |(key, value), out|
220
+ next if value.nil?
221
+
222
+ out[key.to_s.gsub(/_([a-z\d])/) { Regexp.last_match(1).upcase }] = value
223
+ end
224
+ end
225
+
226
+ def blank?(value) = value.nil? || value.to_s.strip.empty?
227
+
228
+ def present?(value) = !(value.nil? || (value.respond_to?(:empty?) && value.empty?))
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waffo
4
+ module Pancake
5
+ # Base class. `status` is the HTTP status (nil when the request never got an answer) and
6
+ # `errors` the envelope's `errors` array, as Waffo sent it.
7
+ class Error < StandardError
8
+ attr_reader :status, :errors
9
+
10
+ def initialize(message = nil, status: nil, errors: [])
11
+ super(message)
12
+ @status = status
13
+ @errors = errors
14
+ end
15
+ end
16
+
17
+ # The key, merchant id or other setting is missing or unusable.
18
+ class ConfigurationError < Error; end
19
+
20
+ # Worth another try later: a network failure, a timeout, a 429 or a 5xx.
21
+ class Unavailable < Error; end
22
+
23
+ # Not worth another try as is: Waffo refused the request (validation, state, auth).
24
+ class Rejected < Error; end
25
+
26
+ # A webhook whose signature, header or timestamp does not check out.
27
+ class InvalidSignature < Error; end
28
+ end
29
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Waffo
6
+ module Pancake
7
+ # Turns the shapes keys arrive in (PEM, PEM with literal "\n" from an env file, CRLF,
8
+ # a one-line body, bare base64) into OpenSSL keys, as @waffo/pancake-ts does.
9
+ module Keys
10
+ module_function
11
+
12
+ def private_key(raw)
13
+ return raw if raw.is_a?(OpenSSL::PKey::RSA)
14
+
15
+ pem = clean(raw, "private")
16
+ kind = pem.include?("BEGIN RSA PRIVATE KEY") ? "RSA PRIVATE KEY" : "PRIVATE KEY"
17
+ OpenSSL::PKey::RSA.new(wrap(body_of(pem, "PRIVATE"), kind))
18
+ rescue OpenSSL::PKey::PKeyError, ArgumentError
19
+ raise ConfigurationError, "The private key is not a valid RSA private key (PKCS#8 or PKCS#1)"
20
+ end
21
+
22
+ def public_key(raw)
23
+ return raw if raw.is_a?(OpenSSL::PKey::RSA)
24
+
25
+ pem = clean(raw, "public")
26
+ kind = pem.include?("BEGIN RSA PUBLIC KEY") ? "RSA PUBLIC KEY" : "PUBLIC KEY"
27
+ OpenSSL::PKey::RSA.new(wrap(body_of(pem, "PUBLIC"), kind))
28
+ rescue OpenSSL::PKey::PKeyError, ArgumentError
29
+ raise ConfigurationError, "The public key is not a valid RSA public key (SPKI or PKCS#1)"
30
+ end
31
+
32
+ def clean(raw, label)
33
+ pem = raw.to_s.gsub("\\n", "\n").gsub("\r\n", "\n").strip
34
+ raise ConfigurationError, "The #{label} key is empty" if pem.empty?
35
+
36
+ pem
37
+ end
38
+
39
+ def body_of(pem, label)
40
+ body = pem.gsub(/-----(BEGIN|END) (RSA )?#{label} KEY-----/, "").gsub(/\s+/, "")
41
+ raise ArgumentError, "no key data" unless body.match?(%r{\A[A-Za-z0-9+/]+=*\z})
42
+
43
+ body
44
+ end
45
+
46
+ def wrap(body, kind)
47
+ "-----BEGIN #{kind}-----\n#{body.scan(/.{1,64}/).join("\n")}\n-----END #{kind}-----\n"
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waffo
4
+ module Pancake
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+
7
+ module Waffo
8
+ module Pancake
9
+ # Verifies a delivery the way @waffo/pancake-ts's verifyWebhook does.
10
+ #
11
+ # event = Waffo::Pancake::Webhook.verify(request.raw_post, request.headers["X-Waffo-Signature"],
12
+ # environment: "prod")
13
+ # event["eventType"] # => "subscription.activated"
14
+ #
15
+ # The header is `t=<unix ms>,v1=<base64>`: an RSA-SHA256 signature over "<t>.<raw body>".
16
+ # Pass the body exactly as received; a re-serialized JSON will not verify.
17
+ module Webhook
18
+ SIGNATURE_HEADER = "X-Waffo-Signature"
19
+
20
+ # Retries reuse the first attempt's header, so the past-facing window covers a whole
21
+ # retry schedule. Deduplicate on the event's `id`; the window only bounds replays.
22
+ DEFAULT_TOLERANCE = 45 * 60
23
+ DEFAULT_FUTURE_TOLERANCE = 60
24
+
25
+ ENVIRONMENTS = %w[test prod].freeze
26
+
27
+ # Waffo's platform keys, as embedded in @waffo/pancake-ts 0.25.0.
28
+ PUBLIC_KEYS = {
29
+ "test" => <<~PEM,
30
+ -----BEGIN PUBLIC KEY-----
31
+ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxnmRY6yMMA3lVqmAU6ZG
32
+ b1sjL/+r/z6E+ZjkXaDAKiqOhk9rpazni0bNsGXwmftTPk9jy2wn+j6JHODD/WH/
33
+ SCnSfvKkLIjy4Hk7BuCgB174C0ydan7J+KgXLkOwgCAxxB68t2tezldwo74ZpXgn
34
+ F49opzMvQ9prEwIAWOE+kV9iK6gx/AckSMtHIHpUesoPDkldpmFHlB2qpf1vsFTZ
35
+ 5kD6DmGl+2GIVK01aChy2lk8pLv0yUMu18v44sLkO5M44TkGPJD9qG09wrvVG2wp
36
+ OTVCn1n5pP8P+HRLcgzbUB3OlZVfdFurn6EZwtyL4ZD9kdkQ4EZE/9inKcp3c1h4
37
+ xwIDAQAB
38
+ -----END PUBLIC KEY-----
39
+ PEM
40
+ "prod" => <<~PEM
41
+ -----BEGIN PUBLIC KEY-----
42
+ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz+xApdTIb4ua+DgZKQ54
43
+ iBsD82ybyhGCLRETONW4Jgbb3A8DUM1LqBk6r/CmTOCHqLalTQHNigvP3R5zkDNX
44
+ iRJz6gA4MJ/+8K0+mnEE2RISQzN+Qu65TNd6svb+INm/kMaftY4uIXr6y6kchtTJ
45
+ dwnQhcKdAL2v7h7IFnkVelQsKxDdb2PqX8xX/qwd01iXvMcpCCaXovUwZsxH2QN5
46
+ ZKBTseJivbhUeyJCco4fdUyxOMHe2ybCVhyvim2uxAl1nkvL5L8RCWMCAV55LLo0
47
+ 9OhmLahz/DYNu13YLVP6dvIT09ZFBYU6Owj1NxdinTynlJCFS9VYwBgmftosSE1U
48
+ dwIDAQAB
49
+ -----END PUBLIC KEY-----
50
+ PEM
51
+ }.freeze
52
+
53
+ EVENT_TYPES = %w[
54
+ order.completed
55
+ subscription.activated subscription.payment_succeeded subscription.renewed subscription.recovered
56
+ subscription.plan_changed subscription.plan_change_scheduled subscription.plan_change_failed
57
+ subscription.canceling subscription.uncanceled subscription.canceled subscription.past_due
58
+ refund.succeeded refund.failed
59
+ ].freeze
60
+
61
+ module_function
62
+
63
+ # Returns the parsed event. Raises InvalidSignature.
64
+ #
65
+ # environment: "test" or "prod" uses that environment's key; nil tries prod, then test.
66
+ # public_key: a key to use instead (skips the lookup and `environment`).
67
+ # tolerance: seconds a timestamp may lie in the past; 0 skips the timestamp check.
68
+ def verify(payload, signature_header, environment: nil, public_key: nil, tolerance: DEFAULT_TOLERANCE,
69
+ future_tolerance: DEFAULT_FUTURE_TOLERANCE, now: Time.now)
70
+ timestamp, signature = parse_header(signature_header)
71
+ check_timestamp!(timestamp, now, tolerance, future_tolerance) if tolerance.positive?
72
+
73
+ input = "#{timestamp}.#{payload}"
74
+ keys = public_key ? [Keys.public_key(public_key)] : keys_for(environment)
75
+ raise InvalidSignature, "Invalid webhook signature" unless keys.any? { |key| valid?(key, signature, input) }
76
+
77
+ JSON.parse(payload.to_s)
78
+ rescue JSON::ParserError
79
+ raise InvalidSignature, "Webhook body is not JSON"
80
+ end
81
+
82
+ # The key for an environment: WAFFO_WEBHOOK_{TEST,PROD}_PUBLIC_KEY, then
83
+ # WAFFO_WEBHOOK_PUBLIC_KEY, then the built-in platform key.
84
+ def public_key_for(environment)
85
+ environment = environment.to_s
86
+ raise ArgumentError, "environment must be test or prod" unless ENVIRONMENTS.include?(environment)
87
+
88
+ custom = ENV["WAFFO_WEBHOOK_#{environment.upcase}_PUBLIC_KEY"] || ENV["WAFFO_WEBHOOK_PUBLIC_KEY"]
89
+ Keys.public_key(custom.to_s.strip.empty? ? PUBLIC_KEYS.fetch(environment) : custom)
90
+ end
91
+
92
+ def keys_for(environment)
93
+ environment ? [public_key_for(environment)] : [public_key_for("prod"), public_key_for("test")]
94
+ end
95
+
96
+ def parse_header(header)
97
+ raise InvalidSignature, "Missing #{SIGNATURE_HEADER} header" if header.nil? || header.to_s.strip.empty?
98
+
99
+ parts = header.to_s.split(",").each_with_object({}) do |pair, out|
100
+ key, value = pair.split("=", 2)
101
+ out[key.strip] = value.strip if value
102
+ end
103
+ timestamp, signature = parts["t"], parts["v1"]
104
+ if timestamp.to_s.empty? || signature.to_s.empty?
105
+ raise InvalidSignature, "Malformed #{SIGNATURE_HEADER} header: missing t or v1"
106
+ end
107
+
108
+ [timestamp, signature]
109
+ end
110
+
111
+ def check_timestamp!(timestamp, now, tolerance, future_tolerance)
112
+ raise InvalidSignature, "Invalid timestamp in #{SIGNATURE_HEADER} header" unless timestamp.match?(/\A\d+\z/)
113
+
114
+ age = now.to_f - (timestamp.to_i / 1000.0)
115
+ return if age <= tolerance && age >= -future_tolerance
116
+
117
+ raise InvalidSignature, "Webhook timestamp outside tolerance window (possible replay attack)"
118
+ end
119
+
120
+ def valid?(key, signature, input)
121
+ key.verify(OpenSSL::Digest::SHA256.new, Base64.strict_decode64(signature), input)
122
+ rescue ArgumentError, OpenSSL::PKey::PKeyError
123
+ false
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pancake/version"
4
+ require_relative "pancake/errors"
5
+ require_relative "pancake/keys"
6
+ require_relative "pancake/client"
7
+ require_relative "pancake/webhook"
8
+
9
+ module Waffo
10
+ # Ruby client for the Waffo Pancake merchant API (https://docs.waffo.ai).
11
+ module Pancake
12
+ end
13
+ end
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: waffo-pancake
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Good First Issue
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ description: Signed requests to the Waffo Pancake merchant API (checkout, subscriptions,
27
+ products, GraphQL) and webhook signature verification. A Ruby port of @waffo/pancake-ts.
28
+ email:
29
+ - hi@goodfirstissue.org
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - LICENSE
36
+ - README.md
37
+ - lib/waffo/pancake.rb
38
+ - lib/waffo/pancake/client.rb
39
+ - lib/waffo/pancake/errors.rb
40
+ - lib/waffo/pancake/keys.rb
41
+ - lib/waffo/pancake/version.rb
42
+ - lib/waffo/pancake/webhook.rb
43
+ homepage: https://github.com/goodfirstissueorg/waffo-pancake-ruby
44
+ licenses:
45
+ - MIT
46
+ metadata:
47
+ homepage_uri: https://github.com/goodfirstissueorg/waffo-pancake-ruby
48
+ source_code_uri: https://github.com/goodfirstissueorg/waffo-pancake-ruby/tree/main
49
+ changelog_uri: https://github.com/goodfirstissueorg/waffo-pancake-ruby/blob/main/CHANGELOG.md
50
+ bug_tracker_uri: https://github.com/goodfirstissueorg/waffo-pancake-ruby/issues
51
+ rubygems_mfa_required: 'true'
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '3.1'
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubygems_version: 4.0.3
67
+ specification_version: 4
68
+ summary: Ruby client for the Waffo Pancake merchant API
69
+ test_files: []